From a55091ba960ac4fbe03a70239968e26ad6d480de Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:23:27 +1000 Subject: [PATCH 1/9] refactor: switch to sessions, change corresponding views, serailizers, services and settings --- .gitignore | 2 +- .../articles/migrations/0001_initial.py | 35 +++-- .../articles/migrations/0002_initial.py | 14 +- .../0003_alter_articleevent_actor_and_more.py | 126 ------------------ .../0004_alter_articleevent_actor_and_more.py | 26 ---- apps/backend/articles/services/articles.py | 2 +- apps/backend/backend/settings/base.py | 47 ++++--- apps/backend/backend/settings/test.py | 10 +- apps/backend/core/middleware.py | 22 +++ apps/backend/core/model_mixins.py | 26 +++- apps/backend/core/views/exception_handler.py | 2 +- apps/backend/dependencies.txt | 5 +- apps/backend/logs/logging/__init__.py | 1 + .../middlewares.py => logs/middleware.py} | 23 +--- apps/backend/logs/migrations/0001_initial.py | 30 ----- apps/backend/pages/tasks.py | 2 +- apps/backend/pages/views.py | 2 +- apps/backend/requirements.txt | 5 +- .../tasks/management/commands/init_tasks.py | 2 +- apps/backend/tasks/migrations/0001_initial.py | 21 +-- ...me_task_path_periodictask_task_and_more.py | 42 ------ ...003_alter_periodictask_options_and_more.py | 47 ------- apps/backend/tasks/schedulers.py | 2 +- apps/backend/users/backends.py | 40 ++++++ apps/backend/users/middleware.py | 12 ++ apps/backend/users/migrations/0001_initial.py | 61 --------- .../0002_emailaddress_created_at.py | 26 ---- ..._alter_emailaddress_created_at_and_more.py | 66 --------- apps/backend/users/models/__init__.py | 3 + apps/backend/users/models/emails.py | 51 +++++++ apps/backend/users/models/sessions.py | 66 +++++++++ .../users/{models.py => models/users.py} | 59 ++------ apps/backend/users/serializers.py | 49 +++---- apps/backend/users/services/sessions.py | 78 +++++++++++ apps/backend/users/services/users.py | 2 +- apps/backend/users/tasks.py | 2 +- apps/backend/users/views.py | 48 ++++--- 37 files changed, 431 insertions(+), 626 deletions(-) delete mode 100644 apps/backend/articles/migrations/0003_alter_articleevent_actor_and_more.py delete mode 100644 apps/backend/articles/migrations/0004_alter_articleevent_actor_and_more.py create mode 100644 apps/backend/core/middleware.py rename apps/backend/{core/middlewares.py => logs/middleware.py} (67%) delete mode 100644 apps/backend/logs/migrations/0001_initial.py delete mode 100644 apps/backend/tasks/migrations/0002_rename_task_path_periodictask_task_and_more.py delete mode 100644 apps/backend/tasks/migrations/0003_alter_periodictask_options_and_more.py create mode 100644 apps/backend/users/backends.py create mode 100644 apps/backend/users/middleware.py delete mode 100644 apps/backend/users/migrations/0001_initial.py delete mode 100644 apps/backend/users/migrations/0002_emailaddress_created_at.py delete mode 100644 apps/backend/users/migrations/0003_alter_emailaddress_created_at_and_more.py create mode 100644 apps/backend/users/models/__init__.py create mode 100644 apps/backend/users/models/emails.py create mode 100644 apps/backend/users/models/sessions.py rename apps/backend/users/{models.py => models/users.py} (71%) create mode 100644 apps/backend/users/services/sessions.py diff --git a/.gitignore b/.gitignore index 78aff383..91bdb035 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,7 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* -logs +*.log *.local *.tsbuildinfo .eslintcache diff --git a/apps/backend/articles/migrations/0001_initial.py b/apps/backend/articles/migrations/0001_initial.py index 877d72ee..97aca507 100644 --- a/apps/backend/articles/migrations/0001_initial.py +++ b/apps/backend/articles/migrations/0001_initial.py @@ -1,6 +1,5 @@ -# Generated by Django 5.2.9 on 2026-02-28 07:11 +# Generated by Django 6.0.3 on 2026-04-05 01:37 -import django.utils.timezone import uuid from django.db import migrations, models @@ -17,9 +16,9 @@ class Migration(migrations.Migration): name='ArticleEvent', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')), - ('annotation', models.TextField(blank=True, null=True, verbose_name='annotation')), - ('event_type', models.IntegerField(choices=[(1, 'Submit'), (2, 'Withdraw'), (3, 'Approve'), (4, 'Reject'), (5, 'Unpublish'), (6, 'Delete')], verbose_name='event type')), - ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='created at')), + ('annotation', models.TextField(blank=True, help_text='The annotation of the article event', null=True, verbose_name='annotation')), + ('event_type', models.IntegerField(choices=[(1, 'Submit'), (2, 'Withdraw'), (3, 'Approve'), (4, 'Reject'), (5, 'Unpublish'), (6, 'Delete')], help_text='The event type of the article event', verbose_name='event type')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='The created DateTime of the article event', verbose_name='created at')), ], options={ 'verbose_name': 'article event', @@ -31,11 +30,11 @@ class Migration(migrations.Migration): name='ArticleSnapshot', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(db_index=True, default='', max_length=60, verbose_name='title')), - ('content', models.JSONField(blank=True, default=dict, verbose_name='content')), - ('content_hash', models.CharField(blank=True, db_index=True, default='', max_length=64, verbose_name='content hash')), - ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='created at')), - ('moderation_status', models.IntegerField(choices=[(1, 'Pending'), (2, 'Withdrawn'), (3, 'Approved'), (4, 'Rejected')], db_index=True, default=1, verbose_name='moderation status')), + ('title', models.CharField(db_index=True, default='', help_text='The title of the article snapshot', max_length=60, verbose_name='title')), + ('content', models.JSONField(blank=True, default=dict, help_text='The content of the article snapshot', verbose_name='content')), + ('content_hash', models.CharField(blank=True, db_index=True, default='', help_text='The content hash of the article snapshot', max_length=64, verbose_name='content hash')), + ('moderation_status', models.IntegerField(choices=[(1, 'Pending'), (2, 'Withdrawn'), (3, 'Approved'), (4, 'Rejected')], db_index=True, default=1, help_text='The moderation status of the article snapshot', verbose_name='moderation status')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='The created DateTime of the article snapshot', verbose_name='created at')), ], options={ 'verbose_name': 'article snapshot', @@ -46,11 +45,11 @@ class Migration(migrations.Migration): migrations.CreateModel( name='PublishedArticle', fields=[ - ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='created at')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), ('updated_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='updated at')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(db_index=True, default='', max_length=60, verbose_name='title')), - ('content', models.JSONField(blank=True, default=dict, verbose_name='content')), + ('title', models.CharField(db_index=True, default='', help_text='The title of the published article', max_length=60, verbose_name='title')), + ('content', models.JSONField(blank=True, default=dict, help_text='The content of the published article', verbose_name='content')), ], options={ 'verbose_name': 'published article', @@ -61,14 +60,14 @@ class Migration(migrations.Migration): migrations.CreateModel( name='SourceArticle', fields=[ - ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='created at')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), ('updated_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='updated at')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')), ('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='deleted')), - ('title', models.CharField(db_index=True, default='', max_length=60, verbose_name='title')), - ('content', models.JSONField(blank=True, default=dict, verbose_name='content')), - ('status', models.IntegerField(choices=[(0, 'Draft'), (1, 'Pending'), (2, 'Published'), (3, 'Rejected'), (4, 'Unpublished'), (5, 'Deleted')], db_index=True, default=0, verbose_name='status')), - ('last_moderation_at', models.DateTimeField(blank=True, null=True, verbose_name='last moderation at')), + ('title', models.CharField(db_index=True, default='', help_text='The title of the article', max_length=60, verbose_name='title')), + ('content', models.JSONField(blank=True, default=dict, help_text='The content of the article', verbose_name='content')), + ('status', models.IntegerField(choices=[(0, 'Draft'), (1, 'Pending'), (2, 'Published'), (3, 'Unpublished')], db_index=True, default=0, help_text='The status of the article', verbose_name='status')), + ('last_moderation_at', models.DateTimeField(blank=True, help_text='The last moderation DateTime of the article', null=True, verbose_name='last moderation at')), ], options={ 'verbose_name': 'source article', diff --git a/apps/backend/articles/migrations/0002_initial.py b/apps/backend/articles/migrations/0002_initial.py index 324716eb..e6526cc2 100644 --- a/apps/backend/articles/migrations/0002_initial.py +++ b/apps/backend/articles/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.9 on 2026-02-28 07:11 +# Generated by Django 6.0.3 on 2026-04-05 01:37 import django.db.models.deletion from django.conf import settings @@ -18,32 +18,32 @@ class Migration(migrations.Migration): migrations.AddField( model_name='articleevent', name='actor', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_events_actors', to=settings.AUTH_USER_MODEL, verbose_name='actor'), + field=models.ForeignKey(help_text='The actor of the article event', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_events', to=settings.AUTH_USER_MODEL, verbose_name='actor'), ), migrations.AddField( model_name='articleevent', name='article_snapshot', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_events', to='articles.articlesnapshot', verbose_name='snapshot'), + field=models.ForeignKey(help_text='The article snapshot of the article event', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_events', to='articles.articlesnapshot', verbose_name='snapshot'), ), migrations.AddField( model_name='sourcearticle', name='author', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_articles', to=settings.AUTH_USER_MODEL, verbose_name='author'), + field=models.ForeignKey(help_text='The author of the article', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_articles', to=settings.AUTH_USER_MODEL, verbose_name='author'), ), migrations.AddField( model_name='publishedarticle', name='source_article', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='published_version', to='articles.sourcearticle', verbose_name='source article'), + field=models.OneToOneField(help_text='The source article of the published version', on_delete=django.db.models.deletion.CASCADE, related_name='published_version', to='articles.sourcearticle', verbose_name='source article'), ), migrations.AddField( model_name='articlesnapshot', name='source_article', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_snapshots', to='articles.sourcearticle', verbose_name='source article'), + field=models.ForeignKey(help_text='The source article of the article snapshot', on_delete=django.db.models.deletion.CASCADE, related_name='article_snapshots', to='articles.sourcearticle', verbose_name='source article'), ), migrations.AddField( model_name='articleevent', name='source_article', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_events', to='articles.sourcearticle', verbose_name='source articles'), + field=models.ForeignKey(help_text='The source article of the article event', on_delete=django.db.models.deletion.CASCADE, related_name='article_events', to='articles.sourcearticle', verbose_name='source articles'), ), migrations.AddIndex( model_name='sourcearticle', diff --git a/apps/backend/articles/migrations/0003_alter_articleevent_actor_and_more.py b/apps/backend/articles/migrations/0003_alter_articleevent_actor_and_more.py deleted file mode 100644 index cdbded16..00000000 --- a/apps/backend/articles/migrations/0003_alter_articleevent_actor_and_more.py +++ /dev/null @@ -1,126 +0,0 @@ -# Generated by Django 6.0.3 on 2026-03-21 06:43 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('articles', '0002_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterField( - model_name='articleevent', - name='actor', - field=models.ForeignKey(help_text='The actor of the article event', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_events_actors', to=settings.AUTH_USER_MODEL, verbose_name='actor'), - ), - migrations.AlterField( - model_name='articleevent', - name='annotation', - field=models.TextField(blank=True, help_text='The annotation of the article event', null=True, verbose_name='annotation'), - ), - migrations.AlterField( - model_name='articleevent', - name='article_snapshot', - field=models.ForeignKey(help_text='The article snapshot of the article event', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_events', to='articles.articlesnapshot', verbose_name='snapshot'), - ), - migrations.AlterField( - model_name='articleevent', - name='created_at', - field=models.DateTimeField(auto_now_add=True, db_index=True, help_text='The created DateTime of the article event', verbose_name='created at'), - ), - migrations.AlterField( - model_name='articleevent', - name='event_type', - field=models.IntegerField(choices=[(1, 'Submit'), (2, 'Withdraw'), (3, 'Approve'), (4, 'Reject'), (5, 'Unpublish'), (6, 'Delete')], help_text='The event type of the article event', verbose_name='event type'), - ), - migrations.AlterField( - model_name='articleevent', - name='source_article', - field=models.ForeignKey(help_text='The source article of the article event', on_delete=django.db.models.deletion.CASCADE, related_name='article_events', to='articles.sourcearticle', verbose_name='source articles'), - ), - migrations.AlterField( - model_name='articlesnapshot', - name='content', - field=models.JSONField(blank=True, default=dict, help_text='The content of the article snapshot', verbose_name='content'), - ), - migrations.AlterField( - model_name='articlesnapshot', - name='content_hash', - field=models.CharField(blank=True, db_index=True, default='', help_text='The content hash of the article snapshot', max_length=64, verbose_name='content hash'), - ), - migrations.AlterField( - model_name='articlesnapshot', - name='created_at', - field=models.DateTimeField(auto_now_add=True, db_index=True, help_text='The created DateTime of the article snapshot', verbose_name='created at'), - ), - migrations.AlterField( - model_name='articlesnapshot', - name='moderation_status', - field=models.IntegerField(choices=[(1, 'Pending'), (2, 'Withdrawn'), (3, 'Approved'), (4, 'Rejected')], db_index=True, default=1, help_text='The moderation status of the article snapshot', verbose_name='moderation status'), - ), - migrations.AlterField( - model_name='articlesnapshot', - name='source_article', - field=models.ForeignKey(help_text='The source article of the article snapshot', on_delete=django.db.models.deletion.CASCADE, related_name='article_snapshots', to='articles.sourcearticle', verbose_name='source article'), - ), - migrations.AlterField( - model_name='articlesnapshot', - name='title', - field=models.CharField(db_index=True, default='', help_text='The title of the article snapshot', max_length=60, verbose_name='title'), - ), - migrations.AlterField( - model_name='publishedarticle', - name='content', - field=models.JSONField(blank=True, default=dict, help_text='The content of the published article', verbose_name='content'), - ), - migrations.AlterField( - model_name='publishedarticle', - name='created_at', - field=models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at'), - ), - migrations.AlterField( - model_name='publishedarticle', - name='source_article', - field=models.OneToOneField(help_text='The source article of the published version', on_delete=django.db.models.deletion.CASCADE, related_name='published_version', to='articles.sourcearticle', verbose_name='source article'), - ), - migrations.AlterField( - model_name='publishedarticle', - name='title', - field=models.CharField(db_index=True, default='', help_text='The title of the published article', max_length=60, verbose_name='title'), - ), - migrations.AlterField( - model_name='sourcearticle', - name='author', - field=models.ForeignKey(help_text='The author of the article', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_articles', to=settings.AUTH_USER_MODEL, verbose_name='author'), - ), - migrations.AlterField( - model_name='sourcearticle', - name='content', - field=models.JSONField(blank=True, default=dict, help_text='The content of the article', verbose_name='content'), - ), - migrations.AlterField( - model_name='sourcearticle', - name='created_at', - field=models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at'), - ), - migrations.AlterField( - model_name='sourcearticle', - name='last_moderation_at', - field=models.DateTimeField(blank=True, help_text='The last moderation DateTime of the article', null=True, verbose_name='last moderation at'), - ), - migrations.AlterField( - model_name='sourcearticle', - name='status', - field=models.IntegerField(choices=[(0, 'Draft'), (1, 'Pending'), (2, 'Published'), (3, 'Unpublished')], db_index=True, default=0, help_text='The status of the article', verbose_name='status'), - ), - migrations.AlterField( - model_name='sourcearticle', - name='title', - field=models.CharField(db_index=True, default='', help_text='The title of the article', max_length=60, verbose_name='title'), - ), - ] diff --git a/apps/backend/articles/migrations/0004_alter_articleevent_actor_and_more.py b/apps/backend/articles/migrations/0004_alter_articleevent_actor_and_more.py deleted file mode 100644 index 71888358..00000000 --- a/apps/backend/articles/migrations/0004_alter_articleevent_actor_and_more.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 6.0.3 on 2026-03-23 10:24 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('articles', '0003_alter_articleevent_actor_and_more'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterField( - model_name='articleevent', - name='actor', - field=models.ForeignKey(help_text='The actor of the article event', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_events', to=settings.AUTH_USER_MODEL, verbose_name='actor'), - ), - migrations.AlterField( - model_name='articleevent', - name='article_snapshot', - field=models.ForeignKey(help_text='The article snapshot of the article event', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_events', to='articles.articlesnapshot', verbose_name='snapshot'), - ), - ] diff --git a/apps/backend/articles/services/articles.py b/apps/backend/articles/services/articles.py index 81a53296..4ecc1985 100644 --- a/apps/backend/articles/services/articles.py +++ b/apps/backend/articles/services/articles.py @@ -7,7 +7,7 @@ from articles.models import SourceArticle, PublishedArticle, ArticleSnapshot, ArticleEvent from core.exceptions import ServiceError -from logs.logging.logger import get_logger +from logs.logging import get_logger logger = get_logger(__name__) diff --git a/apps/backend/backend/settings/base.py b/apps/backend/backend/settings/base.py index 1ba7cd19..946837ee 100644 --- a/apps/backend/backend/settings/base.py +++ b/apps/backend/backend/settings/base.py @@ -1,10 +1,8 @@ # from django.utils.translation import gettext_lazy as _ from django.contrib import messages -from datetime import timedelta from pathlib import Path from environs import Env -from corsheaders.defaults import default_headers BASE_DIR = Path(__file__).resolve().parents[2] @@ -226,8 +224,8 @@ MEDIA_URL = "/media/" MIDDLEWARE = [ - "core.middlewares.RequestMetaMiddleware", - "core.middlewares.RequestLoggingMiddleware", + "core.middleware.RequestMetaMiddleware", + "logs.middleware.RequestLoggingMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.locale.LocaleMiddleware", @@ -235,6 +233,7 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "users.middleware.UserSessionTrackingMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] @@ -340,7 +339,7 @@ """Auth Settings""" -AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"] +AUTHENTICATION_BACKENDS = ["users.backends.EmailBackend"] AUTH_USER_MODEL = "users.User" @@ -411,42 +410,42 @@ """Third-Party Package Settings""" -CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=[]) -CORS_ALLOW_CREDENTIALS = True -CORS_ALLOW_HEADERS = list(default_headers) + [ - "authorization", -] - REST_FRAMEWORK = { - "DEFAULT_PERMISSION_CLASSES": [ - "rest_framework.permissions.IsAuthenticated", + "DEFAULT_RENDERER_CLASSES": [ + 'rest_framework.renderers.JSONRenderer', + 'rest_framework.renderers.BrowsableAPIRenderer', + ], + "DEFAULT_PARSER_CLASSES": [ + 'rest_framework.parsers.JSONParser', + 'rest_framework.parsers.FormParser', + 'rest_framework.parsers.MultiPartParser' ], - "DEFAULT_AUTHENTICATION_CLASSES": [ - "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication", ], - - "EXCEPTION_HANDLER": "core.views.exception_handler.custom_exception_handler", + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], + "DEFAULT_THROTTLE_CLASSES": [], + "DEFAULT_CONTENT_NEGOTIATION_CLASS": "rest_framework.negotiation.DefaultContentNegotiation", + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_PAGINATION_CLASS": "core.pagination.StandardPagination", "PAGE_SIZE": 20, + "EXCEPTION_HANDLER": "core.views.exception_handler.custom_exception_handler", "DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S%z", "DATE_FORMAT": "%Y-%m-%d", - - "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", } +CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=[]) +CORS_ALLOW_CREDENTIALS = True + SPECTACULAR_SETTINGS = { 'TITLE': 'AlienCommons', 'VERSION': '1.0.0', } -SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=env.int("ACCESS_TOKEN_LIFETIME", 10)), - "REFRESH_TOKEN_LIFETIME": timedelta(days=env.int("REFRESH_TOKEN_LIFETIME", 60)), -} - RQ_QUEUES = { "default": { "URL": f"{REDIS_URL}/0", diff --git a/apps/backend/backend/settings/test.py b/apps/backend/backend/settings/test.py index 93eaa70a..f71840ae 100644 --- a/apps/backend/backend/settings/test.py +++ b/apps/backend/backend/settings/test.py @@ -1,5 +1,4 @@ # This settings file is independent and not included in the base->dev/pro/staging structure. -from datetime import timedelta from pathlib import Path from django.utils.translation import gettext_lazy as _ @@ -67,7 +66,7 @@ "rest_framework.permissions.IsAuthenticated", ], "DEFAULT_AUTHENTICATION_CLASSES": [ - "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication", ], "EXCEPTION_HANDLER": "core.views.exception_handler.custom_exception_handler", "DEFAULT_PAGINATION_CLASS": "core.pagination.StandardPagination", @@ -82,13 +81,8 @@ "VERSION": "1.0.0", } -SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=10), - "REFRESH_TOKEN_LIFETIME": timedelta(days=60), -} - MIDDLEWARE = [ - "core.middlewares.RequestMetaMiddleware", + "core.middleware.RequestMetaMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.locale.LocaleMiddleware", diff --git a/apps/backend/core/middleware.py b/apps/backend/core/middleware.py new file mode 100644 index 00000000..14d805a4 --- /dev/null +++ b/apps/backend/core/middleware.py @@ -0,0 +1,22 @@ +from django.utils import timezone + +import uuid + + +class RequestMetaMiddleware: + """ + Add Meta Information to the Request object. + """ + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + request_id = str(uuid.uuid4().hex) + timestamp = timezone.now() + + request.request_id = request_id + request.timestamp = timestamp + + response = self.get_response(request) + + return response diff --git a/apps/backend/core/model_mixins.py b/apps/backend/core/model_mixins.py index 92cb2318..8a005987 100644 --- a/apps/backend/core/model_mixins.py +++ b/apps/backend/core/model_mixins.py @@ -4,17 +4,33 @@ import uuid +class CreatedAtMixin(models.Model): + """ + Provide 'created_at' fields for models. + """ + created_at = models.DateTimeField( + auto_now_add=True, db_index=True, editable=False, + verbose_name=_("created at"), + help_text=_("The created DateTime of the object"), + ) + + class Meta: + abstract = True + + class TimeStampedMixin(models.Model): """ Provide 'created_at' and 'updated_at' fields for models. """ created_at = models.DateTimeField( + auto_now_add=True, db_index=True, editable=False, verbose_name=_("created at"), - auto_now_add=True, db_index=True, editable=False + help_text=_("The created DateTime of the object"), ) updated_at = models.DateTimeField( + auto_now=True, db_index=True, verbose_name=_("updated at"), - auto_now=True, db_index=True + help_text=_("The updated DateTime of the object"), ) class Meta: @@ -26,8 +42,9 @@ class UUIDPrimaryKeyMixin(models.Model): Provide UUID as the primary key for models. """ id = models.UUIDField( + primary_key=True, default=uuid.uuid4, editable=False, verbose_name=_("ID"), - primary_key=True, default=uuid.uuid4, editable=False + help_text=_("The UUID of the object"), ) class Meta: @@ -64,8 +81,9 @@ class SoftDeleteMixin(models.Model): """ # Corresponds to 'self.filter(is_deleted=True)' is_deleted = models.BooleanField( + default=False, db_index=True, verbose_name=_("deleted"), - default=False, db_index=True + help_text=_("Whether the object is deleted"), ) objects = SoftDeleteManager() diff --git a/apps/backend/core/views/exception_handler.py b/apps/backend/core/views/exception_handler.py index 80666f14..4376db93 100644 --- a/apps/backend/core/views/exception_handler.py +++ b/apps/backend/core/views/exception_handler.py @@ -4,7 +4,7 @@ from core.responses import format_api_response from core.exceptions import ServiceError -from logs.logging.logger import get_logger +from logs.logging import get_logger logger = get_logger(__name__) diff --git a/apps/backend/dependencies.txt b/apps/backend/dependencies.txt index 1d1c710f..e0be413f 100644 --- a/apps/backend/dependencies.txt +++ b/apps/backend/dependencies.txt @@ -7,14 +7,13 @@ Django environs django-cors-headers djangorestframework -djangorestframework-simplejwt django-filter django-redis django-rq django-tasks django-tasks-rq drf-spectacular -psycopg2-binary Pillow requests -gunicorn \ No newline at end of file +gunicorn +user-agents \ No newline at end of file diff --git a/apps/backend/logs/logging/__init__.py b/apps/backend/logs/logging/__init__.py index e69de29b..e374e89f 100644 --- a/apps/backend/logs/logging/__init__.py +++ b/apps/backend/logs/logging/__init__.py @@ -0,0 +1 @@ +from .logger import get_logger diff --git a/apps/backend/core/middlewares.py b/apps/backend/logs/middleware.py similarity index 67% rename from apps/backend/core/middlewares.py rename to apps/backend/logs/middleware.py index 8ab5f718..3b97a567 100644 --- a/apps/backend/core/middlewares.py +++ b/apps/backend/logs/middleware.py @@ -1,32 +1,11 @@ from django.utils import timezone -import uuid - from logs.logging.context import add_log_context, clear_log_context -from logs.logging.logger import get_logger +from logs.logging import get_logger logger = get_logger(__name__) -class RequestMetaMiddleware: - """ - Add Meta Information to the Request object. - """ - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - request_id = str(uuid.uuid4().hex) - timestamp = timezone.now() - - request.request_id = request_id - request.timestamp = timestamp - - response = self.get_response(request) - - return response - - class RequestLoggingMiddleware: """ Log at the beginning and end of each request. diff --git a/apps/backend/logs/migrations/0001_initial.py b/apps/backend/logs/migrations/0001_initial.py deleted file mode 100644 index 86384756..00000000 --- a/apps/backend/logs/migrations/0001_initial.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.2.9 on 2026-02-28 07:11 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='FrontendLog', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('level', models.CharField(choices=[('debug', 'DEBUG'), ('info', 'INFO'), ('warn', 'WARN'), ('error', 'ERROR')], db_index=True, editable=False, max_length=10, verbose_name='level')), - ('message', models.TextField(editable=False, verbose_name='message')), - ('extra', models.JSONField(blank=True, editable=False, null=True, verbose_name='extra')), - ('timestamp', models.DateTimeField(editable=False, verbose_name='timestamp')), - ('page', models.CharField(editable=False, max_length=255, verbose_name='page')), - ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), - ], - options={ - 'verbose_name': 'frontend log', - 'verbose_name_plural': 'frontend logs', - }, - ), - ] diff --git a/apps/backend/pages/tasks.py b/apps/backend/pages/tasks.py index 142f902a..cf41d05f 100644 --- a/apps/backend/pages/tasks.py +++ b/apps/backend/pages/tasks.py @@ -1,7 +1,7 @@ from django.tasks import task from core.utils.youtube import fetch_youtube_data -from logs.logging.logger import get_logger +from logs.logging import get_logger logger = get_logger(__name__) diff --git a/apps/backend/pages/views.py b/apps/backend/pages/views.py index 3f141916..8b9bd7f7 100644 --- a/apps/backend/pages/views.py +++ b/apps/backend/pages/views.py @@ -8,7 +8,7 @@ from core.utils.cache import get_cache from core.views.mixins import FormattedResponseMixin -from logs.logging.logger import get_logger +from logs.logging import get_logger logger = get_logger(__name__) diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt index 23205e25..4126d31a 100644 --- a/apps/backend/requirements.txt +++ b/apps/backend/requirements.txt @@ -14,7 +14,6 @@ django-stubs-ext==6.0.2 django-tasks==0.12.0 django-tasks-rq==0.12.0 djangorestframework==3.17.1 -djangorestframework_simplejwt==5.5.1 drf-spectacular==0.29.0 environs==15.0.0 freezegun==1.5.5 @@ -27,7 +26,6 @@ marshmallow==4.2.3 packaging==26.0 pillow==12.2.0 psycopg2-binary==2.9.11 -PyJWT==2.12.1 python-dateutil==2.9.0.post0 python-dotenv==1.2.2 PyYAML==6.0.3 @@ -40,5 +38,8 @@ rq-scheduler==0.14.0 six==1.17.0 sqlparse==0.5.5 typing_extensions==4.15.0 +ua-parser==1.0.1 +ua-parser-builtins==202603 uritemplate==4.2.0 urllib3==2.6.3 +user-agents==2.2.0 diff --git a/apps/backend/tasks/management/commands/init_tasks.py b/apps/backend/tasks/management/commands/init_tasks.py index 2dde12c5..94fbd27f 100644 --- a/apps/backend/tasks/management/commands/init_tasks.py +++ b/apps/backend/tasks/management/commands/init_tasks.py @@ -3,7 +3,7 @@ from tasks.models import IntervalSchedule, PeriodicTask from tasks.periodic_tasks_registry import periodic_tasks from tasks.utils import compute_next_enqueue_at -from logs.logging.logger import get_logger +from logs.logging import get_logger logger = get_logger(__name__) diff --git a/apps/backend/tasks/migrations/0001_initial.py b/apps/backend/tasks/migrations/0001_initial.py index f7ff048a..43808b21 100644 --- a/apps/backend/tasks/migrations/0001_initial.py +++ b/apps/backend/tasks/migrations/0001_initial.py @@ -1,7 +1,6 @@ -# Generated by Django 6.0.3 on 2026-03-21 06:43 +# Generated by Django 6.0.3 on 2026-04-05 01:37 import django.db.models.deletion -import django.utils.timezone import uuid from django.db import migrations, models @@ -19,33 +18,35 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('every', models.PositiveIntegerField(help_text='Number of interval periods to wait before next execution', verbose_name='number of periods')), - ('period', models.CharField(choices=[('seconds', 'seconds'), ('minutes', 'minutes'), ('days', 'days'), ('weeks', 'weeks')], help_text='The type of period used by the schedule', max_length=50, verbose_name='interval period')), + ('period', models.CharField(choices=[('millisecond', 'millisecond'), ('second', 'second'), ('minute', 'minute'), ('hour', 'hour'), ('day', 'day'), ('week', 'week')], help_text='The type of period used by the schedule', max_length=50, verbose_name='interval period')), ], options={ 'verbose_name': 'interval schedule', 'verbose_name_plural': 'interval schedules', + 'constraints': [models.UniqueConstraint(fields=('every', 'period'), name='unique_interval_schedule')], }, ), migrations.CreateModel( name='PeriodicTask', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='A short description for this task', max_length=150, verbose_name='name')), + ('name', models.CharField(help_text='A short, unique description for this task', max_length=150, unique=True, verbose_name='name')), ('description', models.TextField(blank=True, default='', help_text='A extended description for this task', verbose_name='description')), - ('task_path', models.CharField(help_text='The path of the task function to be run', max_length=255, verbose_name='task path')), + ('task', models.CharField(help_text='The path of the task function to be run', max_length=255, verbose_name='task path')), ('queue_name', models.CharField(help_text='The name of the queue that this task belongs to', max_length=100, verbose_name='queue name')), ('args', models.JSONField(blank=True, default=list, help_text='The positional arguments passed to the function', verbose_name='positional arguments')), ('kwargs', models.JSONField(blank=True, default=dict, help_text='The keyword arguments passed to the function', verbose_name='keyword arguments')), ('is_enabled', models.BooleanField(db_index=True, default=True, help_text='Only enabled tasks will be queued', verbose_name='enabled')), - ('started_at', models.DateTimeField(blank=True, db_index=True, default=django.utils.timezone.now, help_text='The start time of the task', verbose_name='start at')), - ('next_run_at', models.DateTimeField(blank=True, help_text='The next run time of the task', verbose_name='next run at')), - ('last_run_at', models.DateTimeField(blank=True, help_text='The last run time of the task', null=True, verbose_name='last run at')), - ('interval', models.ForeignKey(help_text='The time between two task executions', on_delete=django.db.models.deletion.PROTECT, to='tasks.intervalschedule', verbose_name='interval')), + ('last_enqueued_at', models.DateTimeField(blank=True, editable=False, help_text='The last enqueued DateTime of the task', null=True, verbose_name='last enqueued at')), + ('next_enqueue_at', models.DateTimeField(blank=True, help_text='The next enqueue DateTime of the task', verbose_name='next enqueue at')), + ('last_started_at', models.DateTimeField(blank=True, editable=False, help_text='The last started DateTime of the task', null=True, verbose_name='last started at')), + ('last_finished_at', models.DateTimeField(blank=True, editable=False, help_text='The last finished DateTime of the task', null=True, verbose_name='last finished at')), + ('interval', models.ForeignKey(help_text='The time between two task executions', on_delete=django.db.models.deletion.PROTECT, related_name='periodic_tasks', to='tasks.intervalschedule', verbose_name='interval')), ], options={ 'verbose_name': 'periodic task', 'verbose_name_plural': 'periodic tasks', - 'ordering': ['-next_run_at'], + 'ordering': ['-next_enqueue_at'], }, ), ] diff --git a/apps/backend/tasks/migrations/0002_rename_task_path_periodictask_task_and_more.py b/apps/backend/tasks/migrations/0002_rename_task_path_periodictask_task_and_more.py deleted file mode 100644 index 7688d79c..00000000 --- a/apps/backend/tasks/migrations/0002_rename_task_path_periodictask_task_and_more.py +++ /dev/null @@ -1,42 +0,0 @@ -# Generated by Django 6.0.3 on 2026-03-23 10:24 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tasks', '0001_initial'), - ] - - operations = [ - migrations.RenameField( - model_name='periodictask', - old_name='task_path', - new_name='task', - ), - migrations.RemoveField( - model_name='periodictask', - name='started_at', - ), - migrations.AlterField( - model_name='intervalschedule', - name='period', - field=models.CharField(choices=[('millisecond', 'millisecond'), ('second', 'second'), ('minute', 'minute'), ('hour', 'hour'), ('day', 'day'), ('week', 'week')], help_text='The type of period used by the schedule', max_length=50, verbose_name='interval period'), - ), - migrations.AlterField( - model_name='periodictask', - name='interval', - field=models.ForeignKey(help_text='The time between two task executions', on_delete=django.db.models.deletion.PROTECT, related_name='periodic_tasks', to='tasks.intervalschedule', verbose_name='interval'), - ), - migrations.AlterField( - model_name='periodictask', - name='name', - field=models.CharField(help_text='A short, unique description for this task', max_length=150, unique=True, verbose_name='name'), - ), - migrations.AddConstraint( - model_name='intervalschedule', - constraint=models.UniqueConstraint(fields=('every', 'period'), name='unique_interval_schedule'), - ), - ] diff --git a/apps/backend/tasks/migrations/0003_alter_periodictask_options_and_more.py b/apps/backend/tasks/migrations/0003_alter_periodictask_options_and_more.py deleted file mode 100644 index 89034600..00000000 --- a/apps/backend/tasks/migrations/0003_alter_periodictask_options_and_more.py +++ /dev/null @@ -1,47 +0,0 @@ -# Generated by Django 6.0.3 on 2026-03-23 12:41 - -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tasks', '0002_rename_task_path_periodictask_task_and_more'), - ] - - operations = [ - migrations.AlterModelOptions( - name='periodictask', - options={'ordering': ['-next_enqueue_at'], 'verbose_name': 'periodic task', 'verbose_name_plural': 'periodic tasks'}, - ), - migrations.RemoveField( - model_name='periodictask', - name='last_run_at', - ), - migrations.RemoveField( - model_name='periodictask', - name='next_run_at', - ), - migrations.AddField( - model_name='periodictask', - name='last_enqueued_at', - field=models.DateTimeField(blank=True, editable=False, help_text='The last enqueued DateTime of the task', null=True, verbose_name='last enqueued at'), - ), - migrations.AddField( - model_name='periodictask', - name='last_finished_at', - field=models.DateTimeField(blank=True, editable=False, help_text='The last finished DateTime of the task', null=True, verbose_name='last finished at'), - ), - migrations.AddField( - model_name='periodictask', - name='last_started_at', - field=models.DateTimeField(blank=True, editable=False, help_text='The last started DateTime of the task', null=True, verbose_name='last started at'), - ), - migrations.AddField( - model_name='periodictask', - name='next_enqueue_at', - field=models.DateTimeField(blank=True, default=django.utils.timezone.now, help_text='The next enqueue DateTime of the task', verbose_name='next enqueue at'), - preserve_default=False, - ), - ] diff --git a/apps/backend/tasks/schedulers.py b/apps/backend/tasks/schedulers.py index 8b09ce91..a16a79ee 100644 --- a/apps/backend/tasks/schedulers.py +++ b/apps/backend/tasks/schedulers.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from core.utils.cache import get_key -from logs.logging.logger import get_logger +from logs.logging import get_logger from .models import PeriodicTask from .utils import compute_next_enqueue_at diff --git a/apps/backend/users/backends.py b/apps/backend/users/backends.py new file mode 100644 index 00000000..26ed9343 --- /dev/null +++ b/apps/backend/users/backends.py @@ -0,0 +1,40 @@ +from django.contrib.auth.backends import BaseBackend +from django.contrib.auth import get_user_model + +from .models import EmailAddress +from .utils import normalize_email + +User = get_user_model() + + +class EmailBackend(BaseBackend): + def authenticate(self, request, email=None, password=None, **kwargs): + if email is None or password is None: + return None + + email = normalize_email(email) + try: + email_address = EmailAddress.objects.get(email=email) + if not email_address.is_verified: + return None + except EmailAddress.DoesNotExist: + return None + + user = email_address.user + + if user.check_password(password) and user.is_active: + return user + + @staticmethod + def user_can_authenticate(self, user): + """ + Reject users with is_active=False. + """ + return user.is_active + + def get_user(self, user_id): + try: + user = User.objects.get(pk=user_id) + return user + except User.DoesNotExist: + return None diff --git a/apps/backend/users/middleware.py b/apps/backend/users/middleware.py new file mode 100644 index 00000000..64370919 --- /dev/null +++ b/apps/backend/users/middleware.py @@ -0,0 +1,12 @@ +from .services.sessions import update_last_accessed_at + + +class UserSessionTrackingMiddleware(): + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + update_last_accessed_at(request) + + response = self.get_response(request) + return response diff --git a/apps/backend/users/migrations/0001_initial.py b/apps/backend/users/migrations/0001_initial.py deleted file mode 100644 index 0bdb0ed7..00000000 --- a/apps/backend/users/migrations/0001_initial.py +++ /dev/null @@ -1,61 +0,0 @@ -# Generated by Django 5.2.9 on 2026-02-28 07:11 - -import django.db.models.deletion -import django.utils.timezone -import users.models -import uuid -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ] - - operations = [ - migrations.CreateModel( - name='User', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('username', models.CharField(max_length=30, unique=True, verbose_name='username')), - ('avatar', models.ImageField(blank=True, null=True, storage=users.models.AvatarStorage(), upload_to=users.models.avatar_upload_to, verbose_name='avatar')), - ('signature', models.CharField(blank=True, default='This player is somewhat mysterious...', max_length=60, verbose_name='signature')), - ('is_moderator', models.BooleanField(default=False, verbose_name='moderator status')), - ('is_email_verified', models.BooleanField(default=False, verbose_name='email verified')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), - ], - options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - }, - managers=[ - ('objects', users.models.ProfileManager()), - ], - ), - migrations.CreateModel( - name='EmailAddress', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')), - ('email', models.EmailField(max_length=254, unique=True, verbose_name='email')), - ('is_verified', models.BooleanField(default=False, verbose_name='verified')), - ('is_primary', models.BooleanField(default=False, verbose_name='primary')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='related_emails', to=settings.AUTH_USER_MODEL, verbose_name='user')), - ], - options={ - 'verbose_name': 'email', - 'verbose_name_plural': 'emails', - 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_user_primary_emails')], - }, - ), - ] diff --git a/apps/backend/users/migrations/0002_emailaddress_created_at.py b/apps/backend/users/migrations/0002_emailaddress_created_at.py deleted file mode 100644 index a8c71ea7..00000000 --- a/apps/backend/users/migrations/0002_emailaddress_created_at.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Codex on 2026-03-17 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="emailaddress", - name="created_at", - field=models.DateTimeField( - auto_now_add=True, - db_index=True, - default=None, - editable=False, - null=True, - verbose_name="created at", - ), - preserve_default=False, - ), - ] diff --git a/apps/backend/users/migrations/0003_alter_emailaddress_created_at_and_more.py b/apps/backend/users/migrations/0003_alter_emailaddress_created_at_and_more.py deleted file mode 100644 index 06cce5c4..00000000 --- a/apps/backend/users/migrations/0003_alter_emailaddress_created_at_and_more.py +++ /dev/null @@ -1,66 +0,0 @@ -# Generated by Django 6.0.3 on 2026-03-21 06:43 - -import django.db.models.deletion -import users.models -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0002_emailaddress_created_at'), - ] - - operations = [ - migrations.AlterField( - model_name='emailaddress', - name='created_at', - field=models.DateTimeField(auto_now_add=True, db_index=True, help_text='The created DateTime of the email address', verbose_name='created at'), - ), - migrations.AlterField( - model_name='emailaddress', - name='email', - field=models.EmailField(help_text='The email of the email address', max_length=254, unique=True, verbose_name='email'), - ), - migrations.AlterField( - model_name='emailaddress', - name='is_primary', - field=models.BooleanField(default=False, help_text='Whether the email is the primary email of the user', verbose_name='primary'), - ), - migrations.AlterField( - model_name='emailaddress', - name='is_verified', - field=models.BooleanField(default=False, help_text='Whether the email is verified', verbose_name='verified'), - ), - migrations.AlterField( - model_name='emailaddress', - name='user', - field=models.ForeignKey(help_text='The user of the email address', on_delete=django.db.models.deletion.CASCADE, related_name='related_emails', to=settings.AUTH_USER_MODEL, verbose_name='user'), - ), - migrations.AlterField( - model_name='user', - name='avatar', - field=models.ImageField(blank=True, help_text='The avatar of the user', null=True, storage=users.models.AvatarStorage(), upload_to=users.models.avatar_upload_to, verbose_name='avatar'), - ), - migrations.AlterField( - model_name='user', - name='is_email_verified', - field=models.BooleanField(default=False, editable=False, help_text='Whether at least an email of the user is verified', verbose_name='email verified'), - ), - migrations.AlterField( - model_name='user', - name='is_moderator', - field=models.BooleanField(default=False, help_text='Whether the user is a moderator', verbose_name='moderator status'), - ), - migrations.AlterField( - model_name='user', - name='signature', - field=models.CharField(blank=True, default='This player is somewhat mysterious...', help_text='The signature of the user', max_length=60, verbose_name='signature'), - ), - migrations.AlterField( - model_name='user', - name='username', - field=models.CharField(help_text='The username of the user', max_length=30, unique=True, verbose_name='username'), - ), - ] diff --git a/apps/backend/users/models/__init__.py b/apps/backend/users/models/__init__.py new file mode 100644 index 00000000..3b47eba4 --- /dev/null +++ b/apps/backend/users/models/__init__.py @@ -0,0 +1,3 @@ +from .users import User, AvatarStorage, ProfileManager +from .emails import EmailAddress +from .sessions import UserSession diff --git a/apps/backend/users/models/emails.py b/apps/backend/users/models/emails.py new file mode 100644 index 00000000..2f3f88f8 --- /dev/null +++ b/apps/backend/users/models/emails.py @@ -0,0 +1,51 @@ +from django.db import models +from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ + +from core.model_mixins import CreatedAtMixin, UUIDPrimaryKeyMixin + +User = get_user_model() + + +class EmailAddress(UUIDPrimaryKeyMixin, + CreatedAtMixin, + models.Model): + """ + This model extracts email information from the user model, Profile. + """ + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="related_emails", + verbose_name=_("user"), + help_text=_("The user of the email address"), + ) + email = models.EmailField( + unique=True, + verbose_name=_("email"), + help_text=_("The email of the email address"), + ) + is_verified = models.BooleanField( + default=False, + verbose_name=_("verified"), + help_text=_("Whether the email is verified"), + ) + is_primary = models.BooleanField( + default=False, + verbose_name=_("primary"), + help_text=_("Whether the email is the primary email of the user"), + ) + + class Meta: + verbose_name = _("email") + verbose_name_plural = _("emails") + + # One user can only have one primary email + constraints = [ + models.UniqueConstraint( + fields=['user'], + condition=models.Q(is_primary=True), + name='unique_user_primary_emails' + ), + ] + + def __str__(self): + return self.email diff --git a/apps/backend/users/models/sessions.py b/apps/backend/users/models/sessions.py new file mode 100644 index 00000000..a163c3b6 --- /dev/null +++ b/apps/backend/users/models/sessions.py @@ -0,0 +1,66 @@ +from django.db import models +from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ + +from core.model_mixins import UUIDPrimaryKeyMixin, CreatedAtMixin + +User = get_user_model() + + +class UserSession(UUIDPrimaryKeyMixin, + CreatedAtMixin, + models.Model): + """ + This model is used to add a layer of index, + so that user can find all their sessions conveniently. + + This model does not replace Django's default Session model, + and should not be used for authentication and authorization. + However, user can use this model as an entry point + to delete their sessions. + """ + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name='user_sessions', + verbose_name=_("user"), + help_text=_("The user of the session"), + ) + session_key = models.CharField( + max_length=40, unique=True, + verbose_name=_("session key"), + help_text=_("The session's session key"), + ) + user_agent = models.TextField( + blank=True, + verbose_name=_("user agent"), + help_text=_("The session's user agent"), + ) + browser = models.CharField( + blank=True, max_length=100, + verbose_name=_("browser"), + help_text=_("The session's browser"), + ) + os = models.CharField( + blank=True, max_length=100, + verbose_name=_("operating system"), + help_text=_("The session's operating system"), + ) + device = models.CharField( + blank=True, max_length=100, + verbose_name=_("device"), + help_text=_("The session's device"), + ) + ip_address = models.GenericIPAddressField( + null=True, blank=True, + verbose_name=_("IP address"), + help_text=_("The session's IP address"), + ) + last_accessed_at = models.DateField( + verbose_name=_("last accessed at"), + help_text=_("The session's last accessed Date"), + ) + + class Meta: + verbose_name = _("user session") + verbose_name_plural = _("user sessions") + + ordering = ['-created_at'] diff --git a/apps/backend/users/models.py b/apps/backend/users/models/users.py similarity index 71% rename from apps/backend/users/models.py rename to apps/backend/users/models/users.py index bf11da75..68c5ef9a 100644 --- a/apps/backend/users/models.py +++ b/apps/backend/users/models/users.py @@ -73,6 +73,17 @@ class User(UUIDPrimaryKeyMixin, The User model, inherits from AbstractUser. 'email' field, 'first_name' field and 'last_name' field are set to None. Emails are separately managed by 'EmailAddress' Model. + + Hidden Fields declared in AbstractUser and AbstractBaseUser: + - password + - last_login + - is_active + - is_anonymous (@property) + - is_authenticated (@property) + - is_staff + - date_joined + + Always select set 'is_active' to False instead of deleting a user directly. """ default_signature = "This player is somewhat mysterious..." @@ -117,51 +128,3 @@ class Meta: def __str__(self): return self.username - - -class EmailAddress(UUIDPrimaryKeyMixin, - models.Model): - """ - This model extracts email information from the user model, Profile. - """ - user = models.ForeignKey( - User, on_delete=models.CASCADE, related_name="related_emails", - verbose_name=_("user"), - help_text=_("The user of the email address"), - ) - email = models.EmailField( - unique=True, - verbose_name=_("email"), - help_text=_("The email of the email address"), - ) - is_verified = models.BooleanField( - default=False, - verbose_name=_("verified"), - help_text=_("Whether the email is verified"), - ) - is_primary = models.BooleanField( - default=False, - verbose_name=_("primary"), - help_text=_("Whether the email is the primary email of the user"), - ) - created_at = models.DateTimeField( - auto_now_add=True, db_index=True, editable=False, - verbose_name=_("created at"), - help_text=_("The created DateTime of the email address"), - ) - - class Meta: - verbose_name = _("email") - verbose_name_plural = _("emails") - - # One user can only have one primary email - constraints = [ - models.UniqueConstraint( - fields=['user'], - condition=models.Q(is_primary=True), - name='unique_user_primary_emails' - ), - ] - - def __str__(self): - return self.email diff --git a/apps/backend/users/serializers.py b/apps/backend/users/serializers.py index 8f7ea342..cbe4de5d 100644 --- a/apps/backend/users/serializers.py +++ b/apps/backend/users/serializers.py @@ -6,9 +6,6 @@ from rest_framework import serializers from rest_framework.validators import UniqueValidator from rest_framework.exceptions import AuthenticationFailed -from rest_framework_simplejwt.serializers import TokenObtainPairSerializer, TokenRefreshSerializer -from rest_framework_simplejwt.settings import api_settings - import io from PIL import Image @@ -32,7 +29,7 @@ class UserRegisterInputSerializer(serializers.Serializer): error_messages={ 'required': "A username is required", }, - validators = [ + validators=[ UniqueValidator(queryset=User.objects.all(), message="This username has already existed") ], ) @@ -189,32 +186,26 @@ def update(self, instance, validated_data): return instance -class CustomLoginSerializer(TokenObtainPairSerializer): - def validate(self, attrs): - data = super().validate(attrs) - - access_lifetime = api_settings.ACCESS_TOKEN_LIFETIME - refresh_lifetime = api_settings.REFRESH_TOKEN_LIFETIME - data['access_token_lifetime'] = str(access_lifetime.total_seconds()) - data['refresh_token_lifetime'] = str(refresh_lifetime.days) - - return data - - -class CustomLoginRefreshSerializer(TokenRefreshSerializer): - def validate(self, attrs): - try: - data = super().validate(attrs) - except ObjectDoesNotExist: - raise AuthenticationFailed( - self.error_messages.get("no_active_account", "No active account"), - code="no_active_account", - ) - - access_lifetime = api_settings.ACCESS_TOKEN_LIFETIME - data["access_token_lifetime"] = str(access_lifetime.total_seconds()) +class UserLoginSerializer(serializers.Serializer): + email = serializers.EmailField( + required=True, + error_messages={ + 'required': "An email is required", + }, + ) + password = serializers.CharField( + write_only=True, + required=True, + error_messages={ + 'required': "A password is required", + }, + ) - return data + def validate_email(self, value): + """ + Return a normalized email. + """ + return normalize_email(value) class EmailVerifyInputSerializer(serializers.Serializer): diff --git a/apps/backend/users/services/sessions.py b/apps/backend/users/services/sessions.py new file mode 100644 index 00000000..1a3e1def --- /dev/null +++ b/apps/backend/users/services/sessions.py @@ -0,0 +1,78 @@ +from django.utils import timezone +from django.contrib.auth import get_user_model + +import user_agents + +from ..models import UserSession +from logs.logging import get_logger + +logger = get_logger(__name__) +User = get_user_model() + + +def create_user_session(request, user: User): + """ + Create a new UserSession object at login. + """ + session_key = request.session.session_key + + ip = request.META.get('REMOTE_ADDR', None) + if ip is None: + ip = "UNKNOWN" + + user_agent_raw = request.META.get('HTTP_USER_AGENT', None) + if user_agent_raw is None: + user_agent = "UNKNOWN" + browser = "UNKNOWN" + os = "UNKNOWN" + device = "UNKNOWN" + else: + user_agent = user_agents.parse(user_agent_raw) + browser = user_agent.browser.family + os = user_agent.os.family + device = user_agent.device.family + + user_session = UserSession.objects.create( + user=user, session_key=session_key, user_agent=str(user_agent), + broswer=browser, os=os, device=device, + ip_address=ip, last_accessed_at=timezone.now().date() + ) + + logger.info( + f"Created new UserSession object {user_session.id} for user {user.username}" + ) + + +def delete_user_session(request): + """ + Delete a user session at logout, or when user manually revoke a session. + """ + user = request.user + session_key = request.session.session_key + user_session = UserSession.objects.get(session_key=session_key) + + if user_session: + user_session_id = user_session.id + user_session.delete() + logger.info( + f"Deleted UserSession {user_session_id} for session {session_key}", + extra={"user_id": user.id} + ) + else: + logger.warning( + f"(Found at logout) UserSession does not exist for session {session_key}", + extra={"user_id": user.id} + ) + + +def update_last_accessed_at(request): + """ + Update `last_accessed_at` field of UserSession. + """ + today = timezone.now().date() + session_key = request.session.session_key + UserSession.objects.filter( + session_key=session_key + ).exclude( + last_accessed_at=today + ).update(last_accessed_at=today) diff --git a/apps/backend/users/services/users.py b/apps/backend/users/services/users.py index 2e1a8e57..9f50c02b 100644 --- a/apps/backend/users/services/users.py +++ b/apps/backend/users/services/users.py @@ -10,7 +10,7 @@ from core.utils.cache import add_cache, set_cache, get_cache, delete_cache, incr_cache from users.models import EmailAddress from users.tasks import send_verification_email_task -from logs.logging.logger import get_logger +from logs.logging import get_logger logger = get_logger(__name__) User = get_user_model() diff --git a/apps/backend/users/tasks.py b/apps/backend/users/tasks.py index 3650acdc..63b5c934 100644 --- a/apps/backend/users/tasks.py +++ b/apps/backend/users/tasks.py @@ -2,7 +2,7 @@ from django.core.mail import send_mail from django.tasks import task -from logs.logging.logger import get_logger +from logs.logging import get_logger logger = get_logger(__name__) diff --git a/apps/backend/users/views.py b/apps/backend/users/views.py index 464d5870..7df9ff4f 100644 --- a/apps/backend/users/views.py +++ b/apps/backend/users/views.py @@ -1,21 +1,23 @@ -from django.contrib.auth import get_user_model +from django.contrib.auth import get_user_model, authenticate, login, logout + from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from rest_framework.exceptions import AuthenticationFailed from core.views.mixins import ( FormattedResponseMixin, MyListModelMixin, MyRetrieveModelMixin ) from .services.users import register, verify_email +from .services.sessions import create_user_session, delete_user_session from .serializers import ( UserRegisterInputSerializer, UserRegisterOutputSerializer, UserListSerializer, UserRetrieveSerializer, UserUpdateSerializer, - CustomLoginSerializer, - CustomLoginRefreshSerializer, + UserLoginSerializer, EmailVerifyInputSerializer, EmailVerifyOutputSerializer ) @@ -29,9 +31,7 @@ class UserViewSet(MyListModelMixin, FormattedResponseMixin, viewsets.GenericViewSet): """ - A viewset that collects 4 API endpoints which relates to user module. - - Register, User List, User Info, Update (username, signature, avatar) + A viewset that collects API endpoints which relates to User. """ queryset = User.objects.order_by("-date_joined") parser_classes = (MultiPartParser, FormParser, JSONParser) @@ -100,33 +100,45 @@ def me(self, request): ) -class AuthViewSet(FormattedResponseMixin, viewsets.ViewSet): +class SessionViewSet(FormattedResponseMixin, viewsets.ViewSet): """ - A viewset that collects 2 API endpoints which relates to user module. + A viewset that collects API endpoints which relates to UserSession. - Login, Refresh Login Token + Note that login() and logout(), provided by django.contrib.auth, + manages Django built-in Session object; + Meanwhile, create_user_session() and delete_user_session(), provided by services, + manages UserSession object. """ @action(detail=False, methods=['post'], permission_classes=[AllowAny]) def login(self, request): - serializer = CustomLoginSerializer(data=request.data) + serializer = UserLoginSerializer(data=request.data) serializer.is_valid(raise_exception=True) + user = authenticate( + request, email=serializer.validated_data['email'], password=serializer.validated_data['password'] + ) + if user is None: + raise AuthenticationFailed("Invalid credentials") + + login(request, user) + create_user_session(request, user) + return self.format_success_response( message="user login successfully", code="user_login", - data=serializer.validated_data, + data=None, status_code=status.HTTP_200_OK ) - @action(detail=False, methods=['post'], permission_classes=[AllowAny]) - def refresh_login_token(self, request): - serializer = CustomLoginRefreshSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) + def logout(self, request): + logout(request) + delete_user_session(request) return self.format_success_response( - message="login token refreshed successfully", - code="token_refreshed", - data=serializer.validated_data, + message="user logout successfully", + code="user_logout", + data=None, status_code=status.HTTP_200_OK ) From 1277ac2400c7e5cec58ae6441e580a6d0fbac380 Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:06:30 +1000 Subject: [PATCH 2/9] feat: add sliding expiration to session cookie --- apps/backend/backend/settings/base.py | 3 +++ apps/backend/users/backends.py | 2 +- apps/backend/users/middleware.py | 30 +++++++++++++++++++++++++++ apps/backend/users/views.py | 3 +++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/backend/backend/settings/base.py b/apps/backend/backend/settings/base.py index 946837ee..9da921c9 100644 --- a/apps/backend/backend/settings/base.py +++ b/apps/backend/backend/settings/base.py @@ -470,6 +470,9 @@ 'default_avatar/Sword.webp', ] +SESSION_EXPIRY_REFRESH_INTERVAL = 600 +SESSION_EXPIRY_REFRESH_FIELD = 'last_expiry_refresh_at' + VERIFICATION_CODE_RESEND_COOLDOWN = 60 VERIFICATION_CODE_TTL = 600 MAX_VERIFICATION_ATTEMPTS = 10 diff --git a/apps/backend/users/backends.py b/apps/backend/users/backends.py index 26ed9343..839b61ce 100644 --- a/apps/backend/users/backends.py +++ b/apps/backend/users/backends.py @@ -26,7 +26,7 @@ def authenticate(self, request, email=None, password=None, **kwargs): return user @staticmethod - def user_can_authenticate(self, user): + def user_can_authenticate(user): """ Reject users with is_active=False. """ diff --git a/apps/backend/users/middleware.py b/apps/backend/users/middleware.py index 64370919..b3f2f2be 100644 --- a/apps/backend/users/middleware.py +++ b/apps/backend/users/middleware.py @@ -1,5 +1,13 @@ +from django.conf import settings +from django.utils import timezone + +from datetime import datetime, timedelta + +from logs.logging import get_logger from .services.sessions import update_last_accessed_at +logger = get_logger(__name__) + class UserSessionTrackingMiddleware(): def __init__(self, get_response): @@ -8,5 +16,27 @@ def __init__(self, get_response): def __call__(self, request): update_last_accessed_at(request) + now = timezone.now() + should_refresh = False + raw_last_refresh_at = request.session[settings.SESSION_EXPIRY_REFRESH_FIELD] + if raw_last_refresh_at is None: + should_refresh = True + logger.warning( + f"{settings.SESSION_EXPIRY_REFRESH_FIELD} doesn't exist in request.session", + extra={ + 'session_key': request.session.session_key, + } + ) + else: + last_refresh_at = datetime.fromisoformat(raw_last_refresh_at) + interval = settings.SESSION_EXPIRY_REFRESH_INTERVAL + + if now - last_refresh_at >= timedelta(seconds=interval): + should_refresh = True + + if should_refresh: + request.session.set_expiry(1209600) + request.session[settings.SESSION_EXPIRY_REFRESH_FIELD] = now.isoformat() + response = self.get_response(request) return response diff --git a/apps/backend/users/views.py b/apps/backend/users/views.py index 7df9ff4f..908ae03d 100644 --- a/apps/backend/users/views.py +++ b/apps/backend/users/views.py @@ -1,4 +1,6 @@ from django.contrib.auth import get_user_model, authenticate, login, logout +from django.utils import timezone +from django.conf import settings from rest_framework import viewsets, status from rest_framework.decorators import action @@ -121,6 +123,7 @@ def login(self, request): raise AuthenticationFailed("Invalid credentials") login(request, user) + request.session[settings.SESSION_EXPIRY_REFRESH_FIELD] = timezone.now().isoformat() create_user_session(request, user) return self.format_success_response( From 8687f8d879054065538ad0f438a70c2fcd27dcd8 Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:29:17 +1000 Subject: [PATCH 3/9] fixes: several bug fixes --- apps/backend/users/middleware.py | 16 +++-- apps/backend/users/models/sessions.py | 8 +-- apps/backend/users/services/sessions.py | 39 ++++++----- apps/backend/users/tasks.py | 5 ++ apps/backend/users/tests/test_views.py | 92 ++++++++++++++++++++++++- apps/backend/users/urls.py | 4 +- apps/backend/users/views.py | 2 +- 7 files changed, 138 insertions(+), 28 deletions(-) diff --git a/apps/backend/users/middleware.py b/apps/backend/users/middleware.py index b3f2f2be..d42ee624 100644 --- a/apps/backend/users/middleware.py +++ b/apps/backend/users/middleware.py @@ -14,17 +14,26 @@ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): + response = self.get_response(request) + + if not request.user.is_authenticated: + return response + + session_key = request.session.session_key + if session_key is None: + return response + update_last_accessed_at(request) now = timezone.now() should_refresh = False - raw_last_refresh_at = request.session[settings.SESSION_EXPIRY_REFRESH_FIELD] + raw_last_refresh_at = request.session.get(settings.SESSION_EXPIRY_REFRESH_FIELD) if raw_last_refresh_at is None: should_refresh = True logger.warning( f"{settings.SESSION_EXPIRY_REFRESH_FIELD} doesn't exist in request.session", extra={ - 'session_key': request.session.session_key, + 'session_key': session_key, } ) else: @@ -35,8 +44,7 @@ def __call__(self, request): should_refresh = True if should_refresh: - request.session.set_expiry(1209600) + request.session.set_expiry(settings.SESSION_COOKIE_AGE) request.session[settings.SESSION_EXPIRY_REFRESH_FIELD] = now.isoformat() - response = self.get_response(request) return response diff --git a/apps/backend/users/models/sessions.py b/apps/backend/users/models/sessions.py index a163c3b6..c57abf2b 100644 --- a/apps/backend/users/models/sessions.py +++ b/apps/backend/users/models/sessions.py @@ -30,22 +30,22 @@ class UserSession(UUIDPrimaryKeyMixin, help_text=_("The session's session key"), ) user_agent = models.TextField( - blank=True, + null=True, blank=True, verbose_name=_("user agent"), help_text=_("The session's user agent"), ) browser = models.CharField( - blank=True, max_length=100, + null=True, blank=True, max_length=100, verbose_name=_("browser"), help_text=_("The session's browser"), ) os = models.CharField( - blank=True, max_length=100, + null=True, blank=True, max_length=100, verbose_name=_("operating system"), help_text=_("The session's operating system"), ) device = models.CharField( - blank=True, max_length=100, + null=True, blank=True, max_length=100, verbose_name=_("device"), help_text=_("The session's device"), ) diff --git a/apps/backend/users/services/sessions.py b/apps/backend/users/services/sessions.py index 1a3e1def..8ecf4b17 100644 --- a/apps/backend/users/services/sessions.py +++ b/apps/backend/users/services/sessions.py @@ -16,25 +16,24 @@ def create_user_session(request, user: User): """ session_key = request.session.session_key - ip = request.META.get('REMOTE_ADDR', None) - if ip is None: - ip = "UNKNOWN" + ip = request.META.get("REMOTE_ADDR") - user_agent_raw = request.META.get('HTTP_USER_AGENT', None) + user_agent_raw = request.META.get("HTTP_USER_AGENT") if user_agent_raw is None: - user_agent = "UNKNOWN" - browser = "UNKNOWN" - os = "UNKNOWN" - device = "UNKNOWN" + user_agent = None + browser = None + os = None + device = None else: - user_agent = user_agents.parse(user_agent_raw) - browser = user_agent.browser.family - os = user_agent.os.family - device = user_agent.device.family + ua = user_agents.parse(user_agent_raw) + user_agent = str(ua) + browser = ua.browser.family + os = ua.os.family + device = ua.device.family user_session = UserSession.objects.create( user=user, session_key=session_key, user_agent=str(user_agent), - broswer=browser, os=os, device=device, + browser=browser, os=os, device=device, ip_address=ip, last_accessed_at=timezone.now().date() ) @@ -49,19 +48,27 @@ def delete_user_session(request): """ user = request.user session_key = request.session.session_key - user_session = UserSession.objects.get(session_key=session_key) + user_session = UserSession.objects.filter(session_key=session_key).first() if user_session: user_session_id = user_session.id user_session.delete() + extra = {} + if user is not None: + extra["user_id"] = user.id + logger.info( f"Deleted UserSession {user_session_id} for session {session_key}", - extra={"user_id": user.id} + extra=extra ) else: + extra = {} + if user is not None: + extra["user_id"] = user.id + logger.warning( f"(Found at logout) UserSession does not exist for session {session_key}", - extra={"user_id": user.id} + extra=extra ) diff --git a/apps/backend/users/tasks.py b/apps/backend/users/tasks.py index 63b5c934..9fb18e16 100644 --- a/apps/backend/users/tasks.py +++ b/apps/backend/users/tasks.py @@ -20,3 +20,8 @@ def send_verification_email_task(*, to_email, code): fail_silently=False, ) logger.info(f"Email sent to {to_email}") + + +@task +def clean_expired_sessions(): + pass diff --git a/apps/backend/users/tests/test_views.py b/apps/backend/users/tests/test_views.py index d06a7115..b2849b14 100644 --- a/apps/backend/users/tests/test_views.py +++ b/apps/backend/users/tests/test_views.py @@ -8,7 +8,7 @@ from core.tests.factories import create_user from core.tests.testcases import BaseAPITestCase from core.utils.cache import set_cache -from users.models import EmailAddress, User +from users.models import EmailAddress, User, UserSession from users.services.users import _hash_code @@ -148,3 +148,93 @@ def test_verify_email_endpoint_marks_email_as_verified(self): self.assertEqual(response.data["data"]["email"], self.email_address.email) self.assertTrue(self.email_address.is_verified) self.assertTrue(self.user.is_email_verified) + + +class SessionViewTests(BaseAPITestCase): + def setUp(self): + self.user = create_user(username="captain", password="secret123") + self.email_address = EmailAddress.objects.create( + user=self.user, + email="captain@example.com", + is_primary=True, + is_verified=True, + ) + + def test_login_creates_user_session_and_persists_auth_session(self): + response = self.post_json( + reverse("auth-login"), + { + "email": self.email_address.email, + "password": "secret123", + }, + HTTP_USER_AGENT=( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/135.0.0.0 Safari/537.36" + ), + ) + + self.assert_success_response( + response, + status_code=status.HTTP_200_OK, + code="user_login", + message="user login successfully", + ) + + session = self.client.session + self.assertEqual(str(session["_auth_user_id"]), str(self.user.id)) + self.assertIn(settings.SESSION_EXPIRY_REFRESH_FIELD, session) + + user_session = UserSession.objects.get(user=self.user) + self.assertEqual(user_session.session_key, session.session_key) + self.assertEqual(user_session.browser, "Chrome") + self.assertEqual(user_session.os, "Mac OS X") + self.assertEqual(user_session.last_accessed_at, response.wsgi_request.timestamp.date()) + + def test_login_rejects_unverified_email(self): + self.email_address.is_verified = False + self.email_address.save(update_fields=["is_verified"]) + + response = self.post_json( + reverse("auth-login"), + { + "email": self.email_address.email, + "password": "secret123", + }, + ) + + self.assert_error_response( + response, + status_code=status.HTTP_401_UNAUTHORIZED, + code="authentication_failed", + message="Invalid credentials", + ) + self.assertFalse(UserSession.objects.exists()) + + def test_logout_deletes_user_session_and_clears_authentication(self): + self.post_json( + reverse("auth-login"), + { + "email": self.email_address.email, + "password": "secret123", + }, + ) + self.assertEqual(UserSession.objects.count(), 1) + + response = self.post_json(reverse("auth-logout")) + + self.assert_success_response( + response, + status_code=status.HTTP_200_OK, + code="user_logout", + message="user logout successfully", + ) + self.assertEqual(UserSession.objects.count(), 0) + + me_response = self.get_json(reverse("profile-me")) + self.assert_error_response( + me_response, + status_code=status.HTTP_401_UNAUTHORIZED, + code="not_authenticated", + message="Request failed", + ) diff --git a/apps/backend/users/urls.py b/apps/backend/users/urls.py index dabe44f3..e260978e 100644 --- a/apps/backend/users/urls.py +++ b/apps/backend/users/urls.py @@ -1,11 +1,11 @@ from rest_framework.routers import DefaultRouter -from .views import AuthViewSet, UserViewSet, EmailViewSet +from .views import SessionViewSet, UserViewSet, EmailViewSet router = DefaultRouter() router.register(r'profiles', UserViewSet, basename='profile') -router.register(r'auth', AuthViewSet, basename='auth') +router.register(r'sessions', SessionViewSet, basename='session') router.register(r'emails', EmailViewSet, basename='email') urlpatterns = router.urls diff --git a/apps/backend/users/views.py b/apps/backend/users/views.py index 908ae03d..610a69aa 100644 --- a/apps/backend/users/views.py +++ b/apps/backend/users/views.py @@ -135,8 +135,8 @@ def login(self, request): @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) def logout(self, request): - logout(request) delete_user_session(request) + logout(request) return self.format_success_response( message="user logout successfully", From d47890a1ca8641a5c01d67a74013e8ff147ac875 Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:36:36 +1000 Subject: [PATCH 4/9] feat: add clearsessions periodic task --- ...nt_id_alter_articlesnapshot_id_and_more.py | 59 +++++++++++++ .../migrations/0002_alter_periodictask_id.py | 19 +++++ apps/backend/tasks/periodic_tasks_registry.py | 9 ++ apps/backend/users/migrations/0001_initial.py | 82 +++++++++++++++++++ apps/backend/users/tasks.py | 7 +- 5 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 apps/backend/articles/migrations/0003_alter_articleevent_id_alter_articlesnapshot_id_and_more.py create mode 100644 apps/backend/tasks/migrations/0002_alter_periodictask_id.py create mode 100644 apps/backend/users/migrations/0001_initial.py diff --git a/apps/backend/articles/migrations/0003_alter_articleevent_id_alter_articlesnapshot_id_and_more.py b/apps/backend/articles/migrations/0003_alter_articleevent_id_alter_articlesnapshot_id_and_more.py new file mode 100644 index 00000000..8be57202 --- /dev/null +++ b/apps/backend/articles/migrations/0003_alter_articleevent_id_alter_articlesnapshot_id_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 6.0.3 on 2026-04-05 12:34 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('articles', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='articleevent', + name='id', + field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='The UUID of the object', primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='articlesnapshot', + name='id', + field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='The UUID of the object', primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='publishedarticle', + name='created_at', + field=models.DateTimeField(auto_now_add=True, db_index=True, help_text='The created DateTime of the object', verbose_name='created at'), + ), + migrations.AlterField( + model_name='publishedarticle', + name='id', + field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='The UUID of the object', primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='publishedarticle', + name='updated_at', + field=models.DateTimeField(auto_now=True, db_index=True, help_text='The updated DateTime of the object', verbose_name='updated at'), + ), + migrations.AlterField( + model_name='sourcearticle', + name='created_at', + field=models.DateTimeField(auto_now_add=True, db_index=True, help_text='The created DateTime of the object', verbose_name='created at'), + ), + migrations.AlterField( + model_name='sourcearticle', + name='id', + field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='The UUID of the object', primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='sourcearticle', + name='is_deleted', + field=models.BooleanField(db_index=True, default=False, help_text='Whether the object is deleted', verbose_name='deleted'), + ), + migrations.AlterField( + model_name='sourcearticle', + name='updated_at', + field=models.DateTimeField(auto_now=True, db_index=True, help_text='The updated DateTime of the object', verbose_name='updated at'), + ), + ] diff --git a/apps/backend/tasks/migrations/0002_alter_periodictask_id.py b/apps/backend/tasks/migrations/0002_alter_periodictask_id.py new file mode 100644 index 00000000..5e9f724f --- /dev/null +++ b/apps/backend/tasks/migrations/0002_alter_periodictask_id.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.3 on 2026-04-05 12:34 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='periodictask', + name='id', + field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='The UUID of the object', primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/apps/backend/tasks/periodic_tasks_registry.py b/apps/backend/tasks/periodic_tasks_registry.py index d00a5d7a..f628438e 100644 --- a/apps/backend/tasks/periodic_tasks_registry.py +++ b/apps/backend/tasks/periodic_tasks_registry.py @@ -12,6 +12,15 @@ 'period': 'minute' }, }, + { + "name": "clear expired sessions", + "task": "users.tasks.clear_expired_sessions", + "queue_name": "maintenance", + "schedule": { + "every": 1, + "period": "day", + }, + }, { "name": "cleanup unreferenced article images", "task": "articles.tasks.cleanup_unreferenced_article_images", diff --git a/apps/backend/users/migrations/0001_initial.py b/apps/backend/users/migrations/0001_initial.py new file mode 100644 index 00000000..6f11301f --- /dev/null +++ b/apps/backend/users/migrations/0001_initial.py @@ -0,0 +1,82 @@ +# Generated by Django 6.0.3 on 2026-04-05 12:34 + +import django.db.models.deletion +import django.utils.timezone +import users.models.users +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='The UUID of the object', primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(help_text='The username of the user', max_length=30, unique=True, verbose_name='username')), + ('avatar', models.ImageField(blank=True, help_text='The avatar of the user', null=True, storage=users.models.users.AvatarStorage(), upload_to=users.models.users.avatar_upload_to, verbose_name='avatar')), + ('signature', models.CharField(blank=True, default='This player is somewhat mysterious...', help_text='The signature of the user', max_length=60, verbose_name='signature')), + ('is_moderator', models.BooleanField(default=False, help_text='Whether the user is a moderator', verbose_name='moderator status')), + ('is_email_verified', models.BooleanField(default=False, editable=False, help_text='Whether at least an email of the user is verified', verbose_name='email verified')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + }, + managers=[ + ('objects', users.models.users.ProfileManager()), + ], + ), + migrations.CreateModel( + name='UserSession', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='The created DateTime of the object', verbose_name='created at')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='The UUID of the object', primary_key=True, serialize=False, verbose_name='ID')), + ('session_key', models.CharField(help_text="The session's session key", max_length=40, unique=True, verbose_name='session key')), + ('user_agent', models.TextField(blank=True, help_text="The session's user agent", null=True, verbose_name='user agent')), + ('browser', models.CharField(blank=True, help_text="The session's browser", max_length=100, null=True, verbose_name='browser')), + ('os', models.CharField(blank=True, help_text="The session's operating system", max_length=100, null=True, verbose_name='operating system')), + ('device', models.CharField(blank=True, help_text="The session's device", max_length=100, null=True, verbose_name='device')), + ('ip_address', models.GenericIPAddressField(blank=True, help_text="The session's IP address", null=True, verbose_name='IP address')), + ('last_accessed_at', models.DateField(help_text="The session's last accessed Date", verbose_name='last accessed at')), + ('user', models.ForeignKey(help_text='The user of the session', on_delete=django.db.models.deletion.CASCADE, related_name='user_sessions', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'user session', + 'verbose_name_plural': 'user sessions', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='The created DateTime of the object', verbose_name='created at')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='The UUID of the object', primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(help_text='The email of the email address', max_length=254, unique=True, verbose_name='email')), + ('is_verified', models.BooleanField(default=False, help_text='Whether the email is verified', verbose_name='verified')), + ('is_primary', models.BooleanField(default=False, help_text='Whether the email is the primary email of the user', verbose_name='primary')), + ('user', models.ForeignKey(help_text='The user of the email address', on_delete=django.db.models.deletion.CASCADE, related_name='related_emails', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'email', + 'verbose_name_plural': 'emails', + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_user_primary_emails')], + }, + ), + ] diff --git a/apps/backend/users/tasks.py b/apps/backend/users/tasks.py index 9fb18e16..0fd3ce19 100644 --- a/apps/backend/users/tasks.py +++ b/apps/backend/users/tasks.py @@ -1,6 +1,7 @@ from django.conf import settings from django.core.mail import send_mail from django.tasks import task +from django.core.management import call_command from logs.logging import get_logger @@ -24,4 +25,8 @@ def send_verification_email_task(*, to_email, code): @task def clean_expired_sessions(): - pass + """ + Delete expired sessions. + """ + call_command('clearsessions') + logger.info(f"Expired sessions cleared") From 47ea9279f31c439adf3ef1b08049a2411425ea26 Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:47:27 +1000 Subject: [PATCH 5/9] fixes: rename user middleware --- apps/backend/backend/settings/base.py | 2 +- apps/backend/users/middleware.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/backend/settings/base.py b/apps/backend/backend/settings/base.py index 9da921c9..bfe13eb7 100644 --- a/apps/backend/backend/settings/base.py +++ b/apps/backend/backend/settings/base.py @@ -233,7 +233,7 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", - "users.middleware.UserSessionTrackingMiddleware", + "users.middleware.SessionTrackingMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] diff --git a/apps/backend/users/middleware.py b/apps/backend/users/middleware.py index d42ee624..15a4e309 100644 --- a/apps/backend/users/middleware.py +++ b/apps/backend/users/middleware.py @@ -9,7 +9,7 @@ logger = get_logger(__name__) -class UserSessionTrackingMiddleware(): +class SessionTrackingMiddleware(): def __init__(self, get_response): self.get_response = get_response From b603ddc832a868edf36b6cf50346402636a924e5 Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:02:00 +1000 Subject: [PATCH 6/9] fixes: several bug fixes, found by workflows --- apps/backend/logs/logging/__init__.py | 4 +++- apps/backend/users/models/__init__.py | 14 +++++++++++--- apps/backend/users/serializers.py | 2 -- apps/backend/users/tasks.py | 2 +- apps/backend/users/tests/test_views.py | 12 ++++++------ 5 files changed, 21 insertions(+), 13 deletions(-) diff --git a/apps/backend/logs/logging/__init__.py b/apps/backend/logs/logging/__init__.py index e374e89f..638e9de9 100644 --- a/apps/backend/logs/logging/__init__.py +++ b/apps/backend/logs/logging/__init__.py @@ -1 +1,3 @@ -from .logger import get_logger +from .logger import get_logger as get_logger + +__all__ = ["get_logger"] diff --git a/apps/backend/users/models/__init__.py b/apps/backend/users/models/__init__.py index 3b47eba4..39c3a5e7 100644 --- a/apps/backend/users/models/__init__.py +++ b/apps/backend/users/models/__init__.py @@ -1,3 +1,11 @@ -from .users import User, AvatarStorage, ProfileManager -from .emails import EmailAddress -from .sessions import UserSession +from .users import User as User, AvatarStorage as AvatarStorage, ProfileManager as ProfileManager +from .emails import EmailAddress as EmailAddress +from .sessions import UserSession as UserSession + +__all__ = [ + "User", + "AvatarStorage", + "ProfileManager", + "EmailAddress", + "UserSession", +] diff --git a/apps/backend/users/serializers.py b/apps/backend/users/serializers.py index cbe4de5d..c2929e7a 100644 --- a/apps/backend/users/serializers.py +++ b/apps/backend/users/serializers.py @@ -1,11 +1,9 @@ from django.contrib.auth import get_user_model from django.core.validators import RegexValidator from django.core.files.base import ContentFile -from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from rest_framework.validators import UniqueValidator -from rest_framework.exceptions import AuthenticationFailed import io from PIL import Image diff --git a/apps/backend/users/tasks.py b/apps/backend/users/tasks.py index 0fd3ce19..12574028 100644 --- a/apps/backend/users/tasks.py +++ b/apps/backend/users/tasks.py @@ -29,4 +29,4 @@ def clean_expired_sessions(): Delete expired sessions. """ call_command('clearsessions') - logger.info(f"Expired sessions cleared") + logger.info("Expired sessions cleared") diff --git a/apps/backend/users/tests/test_views.py b/apps/backend/users/tests/test_views.py index b2849b14..19d5b638 100644 --- a/apps/backend/users/tests/test_views.py +++ b/apps/backend/users/tests/test_views.py @@ -86,7 +86,7 @@ def test_me_requires_authentication(self): self.assert_error_response( response, - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_403_FORBIDDEN, code="not_authenticated", message="Request failed", ) @@ -162,7 +162,7 @@ def setUp(self): def test_login_creates_user_session_and_persists_auth_session(self): response = self.post_json( - reverse("auth-login"), + reverse("session-login"), { "email": self.email_address.email, "password": "secret123", @@ -196,7 +196,7 @@ def test_login_rejects_unverified_email(self): self.email_address.save(update_fields=["is_verified"]) response = self.post_json( - reverse("auth-login"), + reverse("session-login"), { "email": self.email_address.email, "password": "secret123", @@ -213,7 +213,7 @@ def test_login_rejects_unverified_email(self): def test_logout_deletes_user_session_and_clears_authentication(self): self.post_json( - reverse("auth-login"), + reverse("session-login"), { "email": self.email_address.email, "password": "secret123", @@ -221,7 +221,7 @@ def test_logout_deletes_user_session_and_clears_authentication(self): ) self.assertEqual(UserSession.objects.count(), 1) - response = self.post_json(reverse("auth-logout")) + response = self.post_json(reverse("session-logout")) self.assert_success_response( response, @@ -234,7 +234,7 @@ def test_logout_deletes_user_session_and_clears_authentication(self): me_response = self.get_json(reverse("profile-me")) self.assert_error_response( me_response, - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_403_FORBIDDEN, code="not_authenticated", message="Request failed", ) From a61727a6bece610ae55291ad1155ec106051791b Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:10:30 +1000 Subject: [PATCH 7/9] fixes: fix auth test settings --- apps/backend/backend/settings/test.py | 10 +++++++++- apps/backend/users/views.py | 9 +++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/backend/backend/settings/test.py b/apps/backend/backend/settings/test.py index f71840ae..634cdb22 100644 --- a/apps/backend/backend/settings/test.py +++ b/apps/backend/backend/settings/test.py @@ -1,4 +1,7 @@ -# This settings file is independent and not included in the base->dev/pro/staging structure. +""" +This settings file is independent and not included in the base->dev/pro/staging structure. +""" + from pathlib import Path from django.utils.translation import gettext_lazy as _ @@ -32,6 +35,7 @@ ] AUTH_USER_MODEL = "users.User" +AUTHENTICATION_BACKENDS = ["users.backends.EmailBackend"] DEFAULT_AVATARS = [ "default_avatar/Axe.webp", @@ -41,6 +45,10 @@ "default_avatar/Sword.webp", ] +SESSION_COOKIE_AGE = 1209600 +SESSION_EXPIRY_REFRESH_INTERVAL = 600 +SESSION_EXPIRY_REFRESH_FIELD = "last_expiry_refresh_at" + VERIFICATION_CODE_RESEND_COOLDOWN = 60 VERIFICATION_CODE_TTL = 600 MAX_VERIFICATION_ATTEMPTS = 10 diff --git a/apps/backend/users/views.py b/apps/backend/users/views.py index 610a69aa..70af730b 100644 --- a/apps/backend/users/views.py +++ b/apps/backend/users/views.py @@ -111,6 +111,15 @@ class SessionViewSet(FormattedResponseMixin, viewsets.ViewSet): Meanwhile, create_user_session() and delete_user_session(), provided by services, manages UserSession object. """ + def get_permissions(self): + self.action: str + if self.action in ("login",): + self.permission_classes = [AllowAny] + else: + self.permission_classes = [IsAuthenticated] + + return [permission() for permission in self.permission_classes] + @action(detail=False, methods=['post'], permission_classes=[AllowAny]) def login(self, request): serializer = UserLoginSerializer(data=request.data) From c2102a17dd30fe8f5471f2ab12adc03667184394 Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:14:05 +1000 Subject: [PATCH 8/9] fixes: change error status code expectation --- apps/backend/users/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/users/tests/test_views.py b/apps/backend/users/tests/test_views.py index 19d5b638..0ffd1b3b 100644 --- a/apps/backend/users/tests/test_views.py +++ b/apps/backend/users/tests/test_views.py @@ -205,7 +205,7 @@ def test_login_rejects_unverified_email(self): self.assert_error_response( response, - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_403_FORBIDDEN, code="authentication_failed", message="Invalid credentials", ) From ae53f5e7e8ac4d9783bf271a2c03dc8effdf30c6 Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:19:07 +1000 Subject: [PATCH 9/9] fixes: change error msg expectation --- apps/backend/users/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/users/tests/test_views.py b/apps/backend/users/tests/test_views.py index 0ffd1b3b..0b35d879 100644 --- a/apps/backend/users/tests/test_views.py +++ b/apps/backend/users/tests/test_views.py @@ -207,7 +207,7 @@ def test_login_rejects_unverified_email(self): response, status_code=status.HTTP_403_FORBIDDEN, code="authentication_failed", - message="Invalid credentials", + message="Request failed", ) self.assertFalse(UserSession.objects.exists())