diff --git a/backend/editions/migrations/0003_alter_editionpublicdocument_uploaded_document_and_more.py b/backend/editions/migrations/0003_alter_editionpublicdocument_uploaded_document_and_more.py new file mode 100644 index 0000000..3c69460 --- /dev/null +++ b/backend/editions/migrations/0003_alter_editionpublicdocument_uploaded_document_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0.6 on 2026-07-22 08:25 + +import utils.storage +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('editions', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='editionpublicdocument', + name='uploaded_document', + field=models.FileField(blank=True, null=True, storage=utils.storage.select_public_storage, upload_to='editions_public/%Y/%W/', verbose_name='uploaded document'), + ), + migrations.AlterField( + model_name='projectdatadocument', + name='uploaded_document', + field=models.FileField(blank=True, null=True, upload_to='projects/%Y/%W/', verbose_name='uploaded document'), + ), + ] diff --git a/backend/editions/models.py b/backend/editions/models.py index cf4fdb2..26fc0de 100644 --- a/backend/editions/models.py +++ b/backend/editions/models.py @@ -105,7 +105,7 @@ class EditionPublicDocument(EditionRelatedModel, CommonTimeStampModel): uploaded_document = models.FileField( verbose_name=_("uploaded document"), - upload_to="editions_public/%Y/%m/", + upload_to="editions_public/%Y/%W/", storage=select_public_storage, blank=True, null=True, @@ -414,7 +414,7 @@ class ProjectDataDocument(ProjectDataRelatedModel, CommonTimeStampModel): uploaded_document = models.FileField( verbose_name=_("uploaded document"), - upload_to="projects/%Y/%m/", + upload_to="projects/%Y/%W/", blank=True, null=True, ) diff --git a/backend/funding/settings.py b/backend/funding/settings.py index 5c51e41..f46b7f5 100644 --- a/backend/funding/settings.py +++ b/backend/funding/settings.py @@ -308,6 +308,10 @@ "django.contrib.sessions", "django.contrib.staticfiles", # Third party apps: + "allauth", + "allauth.account", + "allauth.socialaccount", + # TODO: include the providers you want to enable like "allauth.socialaccount.providers.amazon_cognito" "csp", "auditlog", "corsheaders", @@ -342,6 +346,7 @@ "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "inertia.middleware.InertiaMiddleware", + "allauth.account.middleware.AccountMiddleware", "auditlog.middleware.AuditlogMiddleware", # Funding Call middlewares: "editions.middleware.maintenance_mode", # noqa @@ -417,6 +422,7 @@ } } + # Password validation AUTH_PASSWORD_VALIDATORS = [ @@ -441,6 +447,14 @@ AUTH_USER_MODEL = "users.User" +AUTHENTICATION_BACKENDS = [ + # Needed to login by username in Django admin, regardless of `allauth` + "django.contrib.auth.backends.ModelBackend", + # `allauth` specific authentication methods, such as login by email + "allauth.account.auth_backends.AuthenticationBackend", +] + + # Email settings EMAIL_BACKEND = env.str("EMAIL_BACKEND") EMAIL_SEND_METHOD = env.str("EMAIL_SEND_METHOD") diff --git a/backend/funding/urls.py b/backend/funding/urls.py index 2cdb10d..b2deff4 100644 --- a/backend/funding/urls.py +++ b/backend/funding/urls.py @@ -15,10 +15,11 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.urls import path +from django.urls import include, path from editions.views import temp_landing_page urlpatterns = [ path("", temp_landing_page, name="landing-page"), + path("temp-accounts/", include("allauth.urls")), ] diff --git a/backend/orgs/migrations/0003_alter_organizationdocument_uploaded_document.py b/backend/orgs/migrations/0003_alter_organizationdocument_uploaded_document.py new file mode 100644 index 0000000..04cfaef --- /dev/null +++ b/backend/orgs/migrations/0003_alter_organizationdocument_uploaded_document.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-07-22 08:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orgs', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='organizationdocument', + name='uploaded_document', + field=models.FileField(blank=True, null=True, upload_to='orgs/%Y/%W/', verbose_name='uploaded document'), + ), + ] diff --git a/backend/orgs/models.py b/backend/orgs/models.py index c022ed0..390f731 100644 --- a/backend/orgs/models.py +++ b/backend/orgs/models.py @@ -64,7 +64,7 @@ class OrganizationDocument(OrganizationRelatedModel): uploaded_document = models.FileField( verbose_name=_("uploaded document"), - upload_to="orgs/%Y/%m/", + upload_to="orgs/%Y/%W/", blank=True, null=True, ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 73fc443..3fecec6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "blessed~=1.44.0", "chardet~=7.4.3", "django~=6.0.6", + "django-allauth[socialaccount]~=65.18.0", "django-auditlog~=3.4.1", "django-cors-headers~=4.9.0", "django-csp~=4.0.0", diff --git a/backend/users/migrations/0002_profile.py b/backend/users/migrations/0002_profile.py new file mode 100644 index 0000000..65cf596 --- /dev/null +++ b/backend/users/migrations/0002_profile.py @@ -0,0 +1,32 @@ +# Generated by Django 6.0.6 on 2026-07-22 08:19 + +import django.core.validators +import django.db.models.deletion +import utils.models +import utils.storage +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('picture', models.FileField(blank=True, null=True, storage=utils.storage.select_public_storage, upload_to='profiles_public/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=('jpg', 'jpeg', 'png')), utils.models.MaxFileSizeValidator(2097152)], verbose_name='picture')), + ('accepted_newsletter', models.DateTimeField(blank=True, null=True, verbose_name='Accepted to receive newsletters')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'profile', + 'verbose_name_plural': 'profiles', + 'ordering': ['user__email'], + }, + ), + ] diff --git a/backend/users/migrations/0003_alter_profile_picture.py b/backend/users/migrations/0003_alter_profile_picture.py new file mode 100644 index 0000000..8902c57 --- /dev/null +++ b/backend/users/migrations/0003_alter_profile_picture.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0.6 on 2026-07-22 08:25 + +import django.core.validators +import utils.models +import utils.storage +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_profile'), + ] + + operations = [ + migrations.AlterField( + model_name='profile', + name='picture', + field=models.FileField(blank=True, null=True, storage=utils.storage.select_public_storage, upload_to='profiles_public/%Y/%W/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=('jpg', 'jpeg', 'png')), utils.models.MaxFileSizeValidator(2097152)], verbose_name='picture'), + ), + ] diff --git a/backend/users/migrations/0004_loginattempt.py b/backend/users/migrations/0004_loginattempt.py new file mode 100644 index 0000000..9ac201b --- /dev/null +++ b/backend/users/migrations/0004_loginattempt.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.6 on 2026-07-22 12:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_alter_profile_picture'), + ] + + operations = [ + migrations.CreateModel( + name='LoginAttempt', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Timestamp of the creation of the object', verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Timestamp of the last update of the object', verbose_name='updated at')), + ('email', models.EmailField(blank=True, editable=False, max_length=254, verbose_name='email')), + ('success', models.BooleanField(default=False, editable=False, verbose_name='success')), + ('remote_ua', models.CharField(blank=True, editable=False, max_length=150, verbose_name='remote user agent')), + ('remote_addr', models.GenericIPAddressField(blank=True, editable=False, null=True, verbose_name='remote address')), + ('user', models.ForeignKey(blank=True, editable=False, help_text='successful login user account', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='logins', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'login attempt', + 'verbose_name_plural': 'login attempts', + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/backend/users/models.py b/backend/users/models.py index bc1c8a0..6136b32 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -1,9 +1,15 @@ +from auditlog.registry import auditlog +from django.conf import settings from django.contrib.auth.hashers import make_password from django.contrib.auth.models import AbstractUser, UserManager +from django.core.validators import FileExtensionValidator from django.db import models from django.db.models.functions import Lower from django.utils.translation import gettext_lazy as _ +from utils.models import CommonTimeStampModel, MaxFileSizeValidator +from utils.storage import select_public_storage + class CustomUserManager(UserManager): def _create_user(self, email, password, **extra_fields): @@ -52,6 +58,10 @@ class User(AbstractUser): email = models.EmailField(verbose_name=_("email address"), blank=False, null=False, unique=True) + # Type hinting for related models + profile: "models.manager.RelatedManager[Profile]" + + # Model managers objects = CustomUserManager() USERNAME_FIELD = "email" @@ -64,6 +74,81 @@ class Meta: models.UniqueConstraint(Lower("email"), name="email_unique"), ] + def __str__(self) -> str: + return _("User {id}: {email}").format(id=self.pk, email=self.email) + def to_dict(self): # TODO return {} + + +class Profile(models.Model): + """ + Additional user information, not related to the authentication process + """ + + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + verbose_name=_("user"), + related_name="profile", + blank=False, + null=False, + on_delete=models.CASCADE, + ) + + picture = models.FileField( + verbose_name=_("picture"), + upload_to="profiles_public/%Y/%W/", + storage=select_public_storage, + blank=True, + null=True, + validators=( + FileExtensionValidator(allowed_extensions=("jpg", "jpeg", "png")), + MaxFileSizeValidator(settings.MAX_DOCUMENT_SIZE), + ), + ) + + accepted_newsletter = models.DateTimeField(verbose_name=_("Accepted to receive newsletters"), null=True, blank=True) + + class Meta: + verbose_name = _("profile") + verbose_name_plural = _("profiles") + ordering = ["user__email"] + + def __str__(self) -> str: + return _("(User {user_id}) Profile {id}").format(user_id=self.user.pk, id=self.pk) + + +class LoginAttempt(CommonTimeStampModel): + """ + Store user login attempts + """ + + email = models.EmailField(verbose_name=_("email"), blank=True, null=False, editable=False) + user = models.ForeignKey( + User, + verbose_name=_("user"), + related_name="logins", + blank=True, + null=True, + editable=False, + on_delete=models.SET_NULL, + help_text=_("successful login user account"), + ) + success = models.BooleanField(verbose_name=_("success"), editable=False, default=False) + remote_ua = models.CharField( + verbose_name=_("remote user agent"), max_length=150, blank=True, null=False, editable=False + ) + remote_addr = models.GenericIPAddressField(blank=True, null=True, editable=False, verbose_name=_("remote address")) + + class Meta: # type: ignore + verbose_name = _("login attempt") + verbose_name_plural = _("login attempts") + ordering = ("-created_at",) + + def __str__(self) -> str: + return _("Login {id} Email {email}").format(id=self.pk, email=self.email) + + +auditlog.register(Profile) +auditlog.register(User, exclude_fields=["password"]) diff --git a/backend/utils/models.py b/backend/utils/models.py index ceded10..52baa25 100644 --- a/backend/utils/models.py +++ b/backend/utils/models.py @@ -1,4 +1,6 @@ +from django.core.exceptions import ValidationError from django.db import models +from django.utils.deconstruct import deconstructible from django.utils.translation import gettext_lazy as _ @@ -16,3 +18,19 @@ class CommonTimeStampModel(models.Model): class Meta: abstract = True + + +@deconstructible +class MaxFileSizeValidator: + """ + Validator which checks that file size is less or equal to the specified size limit + """ + + def __init__(self, max_size=1024): + self.max_size = max_size + + def __call__(self, value): + if not value and not hasattr(value, "size"): + return + if value.size > self.max_size: + raise ValidationError(_("The file is too large.")) diff --git a/backend/utils/views.py b/backend/utils/views.py new file mode 100644 index 0000000..2aeee6a --- /dev/null +++ b/backend/utils/views.py @@ -0,0 +1,6 @@ +from auditlog.middleware import AuditlogMiddleware +from django.http import HttpRequest + + +def get_remote_addr(request: HttpRequest): + return AuditlogMiddleware._get_remote_addr(request) diff --git a/backend/uv.lock b/backend/uv.lock index a3d79c4..608cdca 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -428,6 +428,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/50/23f9dc45483419a3cc2085b498b25adfbf10642b2941c73e6d2dfaffc9ab/django-6.0.6-py3-none-any.whl", hash = "sha256:25148b1194c47c2e685e5f5e9c5d59c78b075dfd282cb9618861ba6c1708f4d2", size = 8373354, upload-time = "2026-06-03T13:02:41.72Z" }, ] +[[package]] +name = "django-allauth" +version = "65.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/10/2d33c5bc3c0f9d7db1276a1babcaadd147e73eb416ccb1bd9523c6ac925f/django_allauth-65.18.0.tar.gz", hash = "sha256:afb82e2c545b9a5539370ad120468faaed84715e095a7b6e76972dacdc6376fe", size = 2246988, upload-time = "2026-05-29T13:01:26.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/75/5a4c2bf050e38ab16549395a7cd751ddcf3a18305bff4852cfd6c0d94998/django_allauth-65.18.0-py3-none-any.whl", hash = "sha256:e508640c83b94eaf1a9b4e9ac5332dc310dcdc83fca40d58a3f670e15bad1e84", size = 2061080, upload-time = "2026-05-29T13:01:16.532Z" }, +] + +[package.optional-dependencies] +socialaccount = [ + { name = "oauthlib" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] + [[package]] name = "django-auditlog" version = "3.4.1" @@ -616,6 +636,7 @@ dependencies = [ { name = "blessed" }, { name = "chardet" }, { name = "django" }, + { name = "django-allauth", extra = ["socialaccount"] }, { name = "django-auditlog" }, { name = "django-cors-headers" }, { name = "django-csp" }, @@ -675,6 +696,7 @@ requires-dist = [ { name = "blessed", specifier = "~=1.44.0" }, { name = "chardet", specifier = "~=7.4.3" }, { name = "django", specifier = "~=6.0.6" }, + { name = "django-allauth", extras = ["socialaccount"], specifier = "~=65.18.0" }, { name = "django-auditlog", specifier = "~=3.4.1" }, { name = "django-cors-headers", specifier = "~=4.9.0" }, { name = "django-csp", specifier = "~=4.0.0" }, @@ -956,6 +978,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" }, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + [[package]] name = "openpyxl" version = "3.1.5" @@ -1211,6 +1242,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymysql" version = "1.2.0" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d44bde7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +name: funding_call_dev + +services: + + webapp_sqlite: + extends: + file: docker-compose.base.yml + service: funding_call_dev_base + volumes: + - ./backend/.db_sqlite:/var/www/funding_call/backend/.db_sqlite + environment: + - "DATABASE_ENGINE=sqlite3" + ports: + - "5678:5678" + - "5677:5677" +