diff --git a/core/api/urls.py b/core/api/urls.py index e5b9230a..ffff7cca 100644 --- a/core/api/urls.py +++ b/core/api/urls.py @@ -53,6 +53,9 @@ ), path("v3/staff", staff, name="api_staff3"), path("v3/feeds", feeds, name="api_feeds3"), + path( + "v3/obj/user//delete", UserDeleteView.as_view(), name="api_user_delete3" + ), path("v3/obj/", ObjectList.as_view(), name="api_object_list3"), path("v3/obj//new", ObjectNew.as_view(), name="api_object_new3"), path( diff --git a/core/api/views/objects/main.py b/core/api/views/objects/main.py index b9f19339..3337288d 100644 --- a/core/api/views/objects/main.py +++ b/core/api/views/objects/main.py @@ -31,6 +31,8 @@ enum=get_providers_by_operation("list"), description="Which object provider to use", ), + # OpenApiParameter( + # name="lookup", location="query", type=int, description="Lookup field", required=False, default="id"), ], ) class ObjectList( @@ -42,6 +44,7 @@ class ObjectList( """ Endpoint for listing objects with various filters. """ + mutate = False detail = False kind = "list" @@ -183,6 +186,7 @@ class ObjectNew(ObjectAPIView, LookupField, generics.CreateAPIView): """ Endpoint for creating new objects. """ + mutate = True detail = None kind = "new" @@ -209,7 +213,6 @@ def post(self, *args, **kwargs): ], ) class ObjectRetrieve( - ObjectAPIView, LookupField, generics.RetrieveAPIView, @@ -219,6 +222,7 @@ class ObjectRetrieve( """ Endpoint for retrieving objects with various lookups. """ + mutate = False detail = True kind = "retrieve" @@ -260,6 +264,7 @@ class ObjectSingle( """ Endpoint for editing objects with support for lookups. """ + mutate = True detail = None kind = "single" @@ -273,6 +278,8 @@ def check_allow_single(self): def delete(self, *args, **kwargs): if x := self.check_allow_single(): return x + if getattr(self.provider, "delete", None): + return self.provider.delete(self, *args, **kwargs) return super().delete(*args, **kwargs) def put(self, *args, **kwargs): diff --git a/core/api/views/objects/user.py b/core/api/views/objects/user.py index 9e181797..51d78c0c 100644 --- a/core/api/views/objects/user.py +++ b/core/api/views/objects/user.py @@ -1,11 +1,14 @@ import base64 import hashlib +from django.http import JsonResponse +from rest_framework.response import Response + from core.api.serializers.custom import UserOrganizationField from django.conf import settings from django.contrib.admin.models import LogEntry from django.contrib.contenttypes.models import ContentType -from rest_framework import permissions, serializers, validators +from rest_framework import permissions, serializers, validators, status from .base import BaseProvider from ...utils.gravatar import gravatar_url @@ -212,3 +215,13 @@ def get_last_modified_queryset(): .latest("action_time") .action_time ) + + @staticmethod + def delete(request, *args, **kwargs): + """Respond with correct endpoint for deleting a user.""" + return JsonResponse( + status=status.HTTP_304_NOT_MODIFIED, + data={ + "detail": "Not allowed, Please read the docs for the correct endpoint." + }, + ) diff --git a/core/api/views/user.py b/core/api/views/user.py index d4b16d20..f1df632b 100644 --- a/core/api/views/user.py +++ b/core/api/views/user.py @@ -7,6 +7,7 @@ from .. import serializers, utils from ... import models +from core.models import User class UserDetail(generics.RetrieveAPIView): @@ -74,3 +75,14 @@ def get(self, request, format=None): serializer = serializers.TimetableSerializer(current_timetable) return Response(serializer.data) + + +class UserDeleteView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, id): + user: User = models.User.objects.filter(id=id).first() + if user is None: + return Response(status=status.HTTP_404_NOT_FOUND) + user.mark_deleted() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/core/models/user.py b/core/models/user.py index b1d4d592..b4b351f1 100644 --- a/core/models/user.py +++ b/core/models/user.py @@ -3,6 +3,8 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.db.models import Q, CharField +from django.template.loader import render_to_string +from django.urls import reverse from django.utils import timezone from . import timezone_choices, graduating_year_choices @@ -10,6 +12,7 @@ from .post import Announcement from ..utils.choices import calculate_years from ..utils.fields import SetField, ChoiceArrayField +from ..utils.mail import send_mail # Create your models here. @@ -102,6 +105,30 @@ def can_edit(self, obj): def can_approve(self, obj): return obj.approvable(user=self) + def mark_deleted(self): + self.is_active = False + self.last_login = timezone.now() + self.save() + email_template_context = { + "user": self, + "time_deleted": timezone.now(), + "restore_link": settings.SITE_URL + reverse("restore", args=(self.id,)), + } + + send_mail( # todo: frontend needs to make a page for this + f"[ACTION REQUIRED] Your account has been marked for deletion.", + render_to_string( + "core/email/restore_deleted_user.txt", + email_template_context, + ), + None, + [self.email], + html_message=render_to_string( + "core/email/restore_deleted_user.html", + email_template_context, + ), + ) + @classmethod def all(cls): return cls.objects.filter(is_active=True) diff --git a/core/tasks.py b/core/tasks.py index fbd17a32..20078217 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -7,6 +7,7 @@ from celery.utils.log import get_task_logger from django.conf import settings from django.db.models import Value, JSONField, Q +from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _l from django.utils.translation import ngettext from exponent_server_sdk import ( @@ -19,6 +20,7 @@ from requests.exceptions import ConnectionError, HTTPError from core.models import Announcement, User, Event, BlogPost +from core.utils.tasks import get_random_username from metropolis.celery import app logger = get_task_logger(__name__) @@ -47,6 +49,7 @@ def users_with_token(): @app.on_after_configure.connect def setup_periodic_tasks(sender, **kwargs): + sender.add_periodic_task(crontab(hour=0, minute=0), delete_expired_users) sender.add_periodic_task(crontab(hour=18, minute=0), notif_events_singleday) sender.add_periodic_task(crontab(day_of_month=1), run_group_migrations) sender.add_periodic_task( @@ -54,6 +57,29 @@ def setup_periodic_tasks(sender, **kwargs): ) # Delete expired oauth2 tokens from db everyday at 1am +@app.task +def delete_expired_users(): + """Scrub user data from inactive accounts that have not logged in for 14 days. (marked deleted)""" + queryset = User.objects.filter( + is_active=False, last_login__lt=dt.datetime.now() - dt.timedelta(days=14) + ) + queryset.update( # We need to object to not break posts or comments + first_name="Deleted", + last_name="User", + username=get_random_username(), + bio="", + timezone="", + graduating_year=None, + is_teacher=False, + organizations=[], + tags_following=[], + qltrs=None, + saved_blogs=[], + saved_announcements=[], + expo_notif_tokens={}, + ) + + @app.task def run_group_migrations(): from scripts.migrations import migrate_groups diff --git a/core/utils/tasks.py b/core/utils/tasks.py new file mode 100644 index 00000000..2a2107c5 --- /dev/null +++ b/core/utils/tasks.py @@ -0,0 +1,20 @@ +""" +One off random tasks that need to be run on the server. +""" + + +import datetime as dt + +from django.utils.crypto import get_random_string + +from core.models import User + + +def get_random_username(): + """ + Generate a random username that is not already taken. + """ + username = "deleted-" + get_random_string(length=6) + if User.objects.filter(username=username).exists(): + return get_random_username() + return username