Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Warn users and delete user accounts after period of inactivity #2088

Merged
merged 13 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/backend/app/auth/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ async def get_or_create_user(
)
ON CONFLICT (id)
DO UPDATE SET
profile_img = EXCLUDED.profile_img
profile_img = EXCLUDED.profile_img,
last_login_at = NOW()
RETURNING id, username, profile_img, role
)

Expand Down
7 changes: 4 additions & 3 deletions src/backend/app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ class DbUser(BaseModel):
projects_mapped: Optional[list[int]] = None
api_key: Optional[str] = None
registered_at: Optional[AwareDatetime] = None
last_login_at: Optional[AwareDatetime] = None

# Relationships
project_roles: Optional[dict[int, ProjectRole]] = None # project:role pairs
Expand Down Expand Up @@ -208,12 +209,12 @@ async def one(cls, db: Connection, user_identifier: int | str) -> Self:
sql,
{"user_identifier": user_identifier},
)
db_project = await cur.fetchone()
db_user = await cur.fetchone()

if db_project is None:
if db_user is None:
raise KeyError(f"User ({user_identifier}) not found.")

return db_project
return db_user

@classmethod
async def all(
Expand Down
99 changes: 99 additions & 0 deletions src/backend/app/users/user_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,102 @@
# along with FMTM. If not, see <https:#www.gnu.org/licenses/>.
#
"""Logic for user routes."""

from datetime import datetime, timedelta, timezone
from textwrap import dedent

from fastapi import Request
from loguru import logger as log
from osm_login_python.core import Auth
from psycopg import Connection
from psycopg.rows import class_row

from app.auth.providers.osm import get_osm_token, send_osm_message
from app.db.models import DbUser

WARNING_INTERVALS = [21, 14, 7] # Days before deletion
INACTIVITY_THRESHOLD = 2 * 365 # 2 years approx


async def process_inactive_users(
db: Connection,
request: Request,
osm_auth: Auth,
):
"""Identify inactive users, send warnings, and delete accounts."""
now = datetime.now(timezone.utc)
warning_thresholds = [
(now - timedelta(days=INACTIVITY_THRESHOLD - days))
for days in WARNING_INTERVALS
]
deletion_threshold = now - timedelta(days=INACTIVITY_THRESHOLD)

osm_token = get_osm_token(request, osm_auth)

async with db.cursor() as cur:
# Users eligible for warnings
for days, warning_date in zip(
WARNING_INTERVALS, warning_thresholds, strict=False
):
async with db.cursor(row_factory=class_row(DbUser)) as cur:
await cur.execute(
"""
SELECT id, username, last_login_at
FROM users
WHERE last_login_at < %(warning_date)s
AND last_login_at >= %(next_warning_date)s;
""",
{
"warning_date": warning_date,
"next_warning_date": warning_date - timedelta(days=7),
},
)
users_to_warn = await cur.fetchall()

for user in users_to_warn:
await send_warning_email_or_osm(user.id, user.username, days, osm_token)

# Users eligible for deletion
async with db.cursor(row_factory=class_row(DbUser)) as cur:
await cur.execute(
"""
SELECT id, username
FROM users
WHERE last_login_at < %(deletion_threshold)s;
""",
{"deletion_threshold": deletion_threshold},
)
users_to_delete = await cur.fetchall()

for user in users_to_delete:
log.info(f"Deleting user {user.username} due to inactivity.")
await DbUser.delete(db, user.id)


async def send_warning_email_or_osm(
user_id: int,
username: str,
days_remaining: int,
osm_token: str,
):
"""Send warning email or OSM message to the user."""
message_content = dedent(f"""
## Account Deletion Warning

Hi {username},

Your account has been inactive for a long time. To comply with our policy, your
account will be deleted in {days_remaining} days if you do not log in.

Please log in to reset your inactivity period and avoid deletion.

Thank you for being a part of our platform!
""")

send_osm_message(
osm_token=osm_token,
osm_id=user_id,
title="FMTM account deletion warning",
body=message_content,
)
log.info(f"Sent warning to {username}: {days_remaining} days remaining.")
16 changes: 15 additions & 1 deletion src/backend/app/users/user_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,18 @@

from typing import Annotated, List

from fastapi import APIRouter, Depends, Response
from fastapi import APIRouter, Depends, Request, Response
from loguru import logger as log
from psycopg import Connection

from app.auth.providers.osm import init_osm_auth
from app.auth.roles import mapper, super_admin
from app.db.database import db_conn
from app.db.enums import HTTPStatus
from app.db.enums import UserRole as UserRoleEnum
from app.db.models import DbUser
from app.users import user_schemas
from app.users.user_crud import process_inactive_users
from app.users.user_deps import get_user

router = APIRouter(
Expand Down Expand Up @@ -83,3 +85,15 @@ async def delete_user_by_identifier(
await DbUser.delete(db, user.id)
log.info(f"User {user.id} deleted successfully.")
return Response(status_code=HTTPStatus.NO_CONTENT)


@router.post("/process-inactive-users")
async def delete_inactive_users(
request: Request,
db: Annotated[Connection, Depends(db_conn)],
current_user: Annotated[DbUser, Depends(super_admin)],
osm_auth=Depends(init_osm_auth),
):
"""Identify inactive users, send warnings, and delete accounts."""
await process_inactive_users(db, request, osm_auth)
return Response(status_code=HTTPStatus.NO_CONTENT)
38 changes: 38 additions & 0 deletions src/backend/migrations/005-add-user-lastloginat.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- ## Migration add some extra fields.
-- * Add last_login_at to users.
-- * Remove NOT NULL constraint from author_id in projects.

-- Related issues:
-- https://github.com/hotosm/fmtm/issues/1999

-- Start a transaction

BEGIN;

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'users'
AND column_name = 'last_login_at'
) THEN
ALTER TABLE users ADD COLUMN last_login_at TIMESTAMPTZ DEFAULT now();
END IF;
END $$;

DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'projects'
AND column_name = 'author_id'
AND is_nullable = 'NO'
) THEN
ALTER TABLE projects ALTER COLUMN author_id DROP NOT NULL;
END IF;
END $$;

-- Commit the transaction
COMMIT;
5 changes: 3 additions & 2 deletions src/backend/migrations/init/fmtm_base_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ CREATE TABLE public.projects (
id integer NOT NULL,
organisation_id integer,
odkid integer,
author_id integer NOT NULL,
author_id integer,
name character varying,
short_description character varying,
description character varying,
Expand Down Expand Up @@ -357,7 +357,8 @@ CREATE TABLE public.users (
tasks_invalidated integer NOT NULL DEFAULT 0,
projects_mapped integer [],
api_key character varying,
registered_at timestamp with time zone DEFAULT now()
registered_at timestamp with time zone DEFAULT now(),
last_login_at timestamp with time zone DEFAULT now()
);
ALTER TABLE public.users OWNER TO fmtm;

Expand Down
35 changes: 35 additions & 0 deletions src/backend/migrations/revert/005-add-user-lastloginat.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
-- * Remove last_login_at from users.
-- * Restore NOT NULL constraint on author_id in projects.

-- Start a transaction
BEGIN;

-- Remove last_login_at column from users
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'users'
AND column_name = 'last_login_at'
) THEN
ALTER TABLE users DROP COLUMN last_login_at;
END IF;
END $$;

-- Restore NOT NULL constraint on author_id in projects
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'projects'
AND column_name = 'author_id'
AND is_nullable = 'YES'
) THEN
ALTER TABLE projects ALTER COLUMN author_id SET NOT NULL;
END IF;
END $$;

-- Commit the transaction
COMMIT;
Loading