diff --git a/.env.example b/.env.example index 03e6d4d..940a6d0 100644 --- a/.env.example +++ b/.env.example @@ -36,3 +36,7 @@ SERVALA_EMAIL_SSL='False' # If the default OrganizationOrigin is **not** the one with the database ID 1, set it here. SERVALA_DEFAULT_ORIGIN='1' + +SERVALA_KEYCLOAK_CLIENT_ID='portal.servala.com' +SERVALA_KEYCLOAK_CLIENT_SECRET='' +SERVALA_KEYCLOAK_SERVER_URL='' diff --git a/pyproject.toml b/pyproject.toml index bdc67c2..1893952 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,9 +6,13 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "argon2-cffi>=23.1.0", + "cryptography>=44.0.2", "django==5.2b1", + "django-allauth>=65.5.0", "pillow>=11.1.0", "psycopg2-binary>=2.9.10", + "pyjwt>=2.10.1", + "requests>=2.32.3", ] [dependency-groups] @@ -30,3 +34,6 @@ known_first_party = "servala" [tool.flake8] max-line-length = 160 exclude = ".venv" + +[tool.djlint] +extend_exclude = "src/servala/static/mazer" diff --git a/src/servala/core/migrations/0002_billingentity_created_at_billingentity_updated_at_and_more.py b/src/servala/core/migrations/0002_billingentity_created_at_billingentity_updated_at_and_more.py new file mode 100644 index 0000000..83d5c00 --- /dev/null +++ b/src/servala/core/migrations/0002_billingentity_created_at_billingentity_updated_at_and_more.py @@ -0,0 +1,89 @@ +# Generated by Django 5.2b1 on 2025-03-17 06:19 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="billingentity", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + verbose_name="Created", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="billingentity", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + migrations.AddField( + model_name="organization", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + verbose_name="Created", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="organization", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + migrations.AddField( + model_name="organizationmembership", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + verbose_name="Created", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="organizationmembership", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + migrations.AddField( + model_name="organizationorigin", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + verbose_name="Created", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="organizationorigin", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + migrations.AddField( + model_name="user", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + verbose_name="Created", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="user", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + ] diff --git a/src/servala/core/models/mixins.py b/src/servala/core/models/mixins.py new file mode 100644 index 0000000..ac8c3b0 --- /dev/null +++ b/src/servala/core/models/mixins.py @@ -0,0 +1,15 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class ServalaModelMixin(models.Model): + created_at = models.DateTimeField( + auto_now_add=True, editable=False, verbose_name=_("Created") + ) + updated_at = models.DateTimeField( + auto_now=True, editable=False, verbose_name=_("Last updated") + ) + + class Meta: + abstract = True + ordering = ("-created_at",) diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index ce0b43d..0419175 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -1,8 +1,11 @@ +from django.conf import settings from django.db import models from django.utils.translation import gettext_lazy as _ +from .mixins import ServalaModelMixin -class Organization(models.Model): + +class Organization(ServalaModelMixin, models.Model): name = models.CharField(max_length=100, verbose_name=_("Name")) billing_entity = models.ForeignKey( @@ -25,6 +28,24 @@ class Organization(models.Model): verbose_name=_("Members"), ) + def set_owner(self, user): + OrganizationMembership.objects.filter(user=user, organization=self).delete() + OrganizationMembership.objects.create( + user=user, organization=self, role=OrganizationRole.OWNER + ) + + @classmethod + def create_organization(cls, instance, owner): + try: + instance.origin + except Exception: + instance.origin = OrganizationOrigin.objects.get( + pk=settings.SERVALA_DEFAULT_ORIGIN + ) + instance.save() + instance.set_owner(owner) + return instance + class Meta: verbose_name = _("Organization") verbose_name_plural = _("Organizations") @@ -33,7 +54,7 @@ class Organization(models.Model): return self.name -class BillingEntity(models.Model): +class BillingEntity(ServalaModelMixin, models.Model): """ Every organization has a billing entity, though billing entities may be shared across different organizations. @@ -53,7 +74,7 @@ class BillingEntity(models.Model): return self.name -class OrganizationOrigin(models.Model): +class OrganizationOrigin(ServalaModelMixin, models.Model): """ Every organization has an origin, though origins may be shared across different organizations. The default origin @@ -77,7 +98,7 @@ class OrganizationRole(models.TextChoices): OWNER = "owner", _("Owner") -class OrganizationMembership(models.Model): +class OrganizationMembership(ServalaModelMixin, models.Model): """Through-model for the many-to-many relationship between organizations and users.""" user = models.ForeignKey( diff --git a/src/servala/core/models/user.py b/src/servala/core/models/user.py index 66f420d..12545a8 100644 --- a/src/servala/core/models/user.py +++ b/src/servala/core/models/user.py @@ -2,6 +2,8 @@ from django.contrib.auth.models import AbstractBaseUser, BaseUserManager from django.db import models from django.utils.translation import gettext_lazy as _ +from .mixins import ServalaModelMixin + class UserManager(BaseUserManager): """ @@ -30,7 +32,7 @@ class UserManager(BaseUserManager): return self.create_user(email, password, **extra_fields) -class User(AbstractBaseUser): +class User(ServalaModelMixin, AbstractBaseUser): """The Django model provides a password and last_login field.""" objects = UserManager() diff --git a/src/servala/frontend/__init__.py b/src/servala/frontend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/servala/frontend/apps.py b/src/servala/frontend/apps.py new file mode 100644 index 0000000..31c808f --- /dev/null +++ b/src/servala/frontend/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FrontendConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "servala.frontend" diff --git a/src/servala/frontend/context_processors.py b/src/servala/frontend/context_processors.py new file mode 100644 index 0000000..1a180d8 --- /dev/null +++ b/src/servala/frontend/context_processors.py @@ -0,0 +1,5 @@ +def add_organizations(request): + if not request.user.is_authenticated: + return {"user_organizations": []} + + return {"user_organizations": request.user.organizations.all()} diff --git a/src/servala/frontend/forms/__init__.py b/src/servala/frontend/forms/__init__.py new file mode 100644 index 0000000..8169b61 --- /dev/null +++ b/src/servala/frontend/forms/__init__.py @@ -0,0 +1,3 @@ +from .organization import OrganizationCreateForm + +__all__ = ["OrganizationCreateForm"] diff --git a/src/servala/frontend/forms/auth.py b/src/servala/frontend/forms/auth.py new file mode 100644 index 0000000..3bd8a89 --- /dev/null +++ b/src/servala/frontend/forms/auth.py @@ -0,0 +1,11 @@ +from django.forms import Form + +from servala.core.models.user import User + + +class ServalaSignupForm(Form): + company = User._meta.get_field("company").formfield() + + def signup(self, request, user): + user.company = self.cleaned_data.get("company") + user.save() diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py new file mode 100644 index 0000000..b6a3391 --- /dev/null +++ b/src/servala/frontend/forms/organization.py @@ -0,0 +1,9 @@ +from django.forms import ModelForm + +from servala.core.models import Organization + + +class OrganizationCreateForm(ModelForm): + class Meta: + model = Organization + fields = ("name",) diff --git a/src/servala/frontend/forms/renderers.py b/src/servala/frontend/forms/renderers.py new file mode 100644 index 0000000..f8cdb5d --- /dev/null +++ b/src/servala/frontend/forms/renderers.py @@ -0,0 +1,28 @@ +from django.forms.renderers import TemplatesSetting + + +def inject_class(f, class_name): + def inner(*args, **kwargs): + result = f(*args, **kwargs) + class_list = result.get("class", "") + class_list = f"{class_list} {class_name}".strip() + result["class"] = class_list + return result + + return inner + + +class VerticalFormRenderer(TemplatesSetting): + form_template_name = "frontend/forms/form.html" + field_template_name = "frontend/forms/vertical_field.html" + + def render(self, template_name, context, request=None): + if field := context.get("field"): + if field.field.widget.input_type == "checkbox": + class_name = "form-check-input" + else: + class_name = "form-control" + field.build_widget_attrs = inject_class( + field.build_widget_attrs, class_name + ) + return super().render(template_name, context, request) diff --git a/src/servala/frontend/templates/account/login.html b/src/servala/frontend/templates/account/login.html new file mode 100644 index 0000000..5a31aea --- /dev/null +++ b/src/servala/frontend/templates/account/login.html @@ -0,0 +1,45 @@ +{% extends "frontend/base.html" %} +{% load static i18n %} +{% load allauth account socialaccount %} +{% block html_title %} + {% block page_title %} + {% translate "Sign In" %} + {% endblock page_title %} +{% endblock html_title %} +{% block card_content %} + {% if SOCIALACCOUNT_ENABLED %} + {% get_providers as socialaccount_providers %} + {% if socialaccount_providers %} + {% for provider in socialaccount_providers %} + {% provider_login_url provider process=process scope=scope auth_params=auth_params as href %} +
+ {% endfor %} + {% endif %} + {% endif %} +