diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 6fd6c67..fec3fa4 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -27,6 +27,12 @@ class Organization(ServalaModelMixin, 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 + ) + class Meta: verbose_name = _("Organization") verbose_name_plural = _("Organizations") 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/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..3100278 --- /dev/null +++ b/src/servala/frontend/templates/account/login.html @@ -0,0 +1,53 @@ +{% 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 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 %} +
+ {% csrf_token %} + +
+ {% endfor %} + {% endif %} + {% endif %} +
+ + + {% translate "Log in with email and password instead" %} + +
+ {% url 'account_login' as form_action %} + {% translate "Sign In" as form_submit_label %} + {% include "includes/form.html" %} +
+
+
+
+
+
+{% endblock content %} diff --git a/src/servala/frontend/templates/frontend/forms/form.html b/src/servala/frontend/templates/frontend/forms/form.html new file mode 100644 index 0000000..a9e8c47 --- /dev/null +++ b/src/servala/frontend/templates/frontend/forms/form.html @@ -0,0 +1,19 @@ +{% if errors %} + +{% endif %} +
+
+ {% for field, errors in fields %}{{ field.as_field_group }}{% endfor %} + {% for field in hidden_fields %}{{ field }}{% endfor %} +
+
diff --git a/src/servala/frontend/templates/frontend/forms/vertical_field.html b/src/servala/frontend/templates/frontend/forms/vertical_field.html new file mode 100644 index 0000000..bcbf8d6 --- /dev/null +++ b/src/servala/frontend/templates/frontend/forms/vertical_field.html @@ -0,0 +1,26 @@ +{% load i18n %} +
+
+ {% if field.field.widget.input_type != "checkbox" or field.field.widget.allow_multiple_selected %} + + {% endif %} + {% if field.use_fieldset %} +
+ {% endif %} + {{ field }} + {% if field.field.widget.input_type == "checkbox" and not field.field.widget.allow_multiple_selected %} + + {% endif %} + {% if field.use_fieldset %}
{% endif %} + {% for text in field.errors %}
{{ text }}
{% endfor %} + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
+
diff --git a/src/servala/frontend/templates/frontend/organizations/create.html b/src/servala/frontend/templates/frontend/organizations/create.html new file mode 100644 index 0000000..ca46190 --- /dev/null +++ b/src/servala/frontend/templates/frontend/organizations/create.html @@ -0,0 +1,16 @@ +{% extends "frontend/base.html" %} +{% load i18n %} +{% block html_title %} + {% block page_title %} + {% translate "Create a new organization" %} + {% endblock page_title %} +{% endblock html_title %} +{% block content %} +
+
+
+
{% include "includes/form.html" %}
+
+
+
+{% endblock content %} diff --git a/src/servala/frontend/templates/includes/form.html b/src/servala/frontend/templates/includes/form.html new file mode 100644 index 0000000..d8a01e5 --- /dev/null +++ b/src/servala/frontend/templates/includes/form.html @@ -0,0 +1,14 @@ +{% load i18n %} +
+ {% csrf_token %} + {{ form }} + +
diff --git a/src/servala/frontend/templates/includes/sidebar.html b/src/servala/frontend/templates/includes/sidebar.html index 287977a..8c47293 100644 --- a/src/servala/frontend/templates/includes/sidebar.html +++ b/src/servala/frontend/templates/includes/sidebar.html @@ -56,42 +56,67 @@ diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py new file mode 100644 index 0000000..fbaaa08 --- /dev/null +++ b/src/servala/frontend/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from servala.frontend import views + +urlpatterns = [ + path("accounts/profile/", views.ProfileView.as_view(), name="profile"), + path("accounts/logout/", views.LogoutView.as_view(), name="logout"), + path( + "organizations/create", + views.OrganizationCreateView.as_view(), + name="organization.create", + ), + path("", views.IndexView.as_view(), name="index"), +] diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py new file mode 100644 index 0000000..20b4398 --- /dev/null +++ b/src/servala/frontend/views/__init__.py @@ -0,0 +1,10 @@ +from .auth import LogoutView +from .generic import IndexView, ProfileView +from .organization import OrganizationCreateView + +__all__ = [ + "IndexView", + "LogoutView", + "OrganizationCreateView", + "ProfileView", +] diff --git a/src/servala/frontend/views/auth.py b/src/servala/frontend/views/auth.py new file mode 100644 index 0000000..6b351f1 --- /dev/null +++ b/src/servala/frontend/views/auth.py @@ -0,0 +1,12 @@ +from allauth.account.internal import flows +from allauth.account.utils import get_next_redirect_url +from django.shortcuts import redirect +from django.views import View + + +class LogoutView(View): + + def post(self, request): + flows.logout.logout(request) + url = get_next_redirect_url(request, "next") or "/" + return redirect(url) diff --git a/src/servala/frontend/views.py b/src/servala/frontend/views/generic.py similarity index 100% rename from src/servala/frontend/views.py rename to src/servala/frontend/views/generic.py diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py new file mode 100644 index 0000000..9bc1397 --- /dev/null +++ b/src/servala/frontend/views/organization.py @@ -0,0 +1,16 @@ +from django.views.generic import FormView + +from servala.frontend.forms import OrganizationCreateForm + + +class OrganizationCreateView(FormView): + form_class = OrganizationCreateForm + template_name = "frontend/organizations/create.html" + + def form_valid(self, form): + form.save() + form.instance.set_owner(self.request.user) + return super().form_valid(form) + + def get_success_url(self): + return "/" diff --git a/src/servala/settings.py b/src/servala/settings.py index 785169a..36d7673 100644 --- a/src/servala/settings.py +++ b/src/servala/settings.py @@ -95,6 +95,7 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django.forms", "servala.frontend", "allauth", "allauth.account", @@ -112,7 +113,9 @@ MIDDLEWARE = [ "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "allauth.account.middleware.AccountMiddleware", + "django.contrib.auth.middleware.LoginRequiredMiddleware", ] +LOGIN_URL = "account_login" ROOT_URLCONF = "servala.urls" STATIC_URL = "static/" # CSS, JavaScript, etc. @@ -144,22 +147,23 @@ TEMPLATES = [ "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "django.template.context_processors.static", + "servala.frontend.context_processors.add_organizations", ], "loaders": template_loaders, }, }, ] +FORM_RENDERER = "servala.frontend.forms.renderers.VerticalFormRenderer" MESSAGE_TAGS = { messages.ERROR: "danger", } AUTH_USER_MODEL = "core.User" ACCOUNT_USER_MODEL_USERNAME_FIELD = None -ACCOUNT_EMAIL_REQUIRED = True ACCOUNT_UNIQUE_EMAIL = True -ACCOUNT_USERNAME_REQUIRED = False -ACCOUNT_AUTHENTICATION_METHOD = "email" +ACCOUNT_LOGIN_METHODS = {"email"} +ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"] AUTHENTICATION_BACKENDS = [ # Needed to login by username in Django admin, regardless of `allauth` diff --git a/src/servala/static/img/keycloak.svg b/src/servala/static/img/keycloak.svg new file mode 100644 index 0000000..cdcd0fa --- /dev/null +++ b/src/servala/static/img/keycloak.svg @@ -0,0 +1,6 @@ + diff --git a/src/servala/urls.py b/src/servala/urls.py index 8ddedb6..d1d40ec 100644 --- a/src/servala/urls.py +++ b/src/servala/urls.py @@ -6,7 +6,7 @@ from django.urls import path from django.urls.conf import include from django.utils.translation import gettext_lazy as _ -from servala.frontend import views +from servala.frontend import urls admin.site.site_title = _("Servala Admin") admin.site.site_header = _("Servala Management") @@ -14,13 +14,12 @@ admin.site.index_title = _("Dashboard") admin.site.login = secure_admin_login(admin.site.login) urlpatterns = [ + path("admin/", admin.site.urls), + path("", include((urls, "servala.frontend"), namespace="frontend")), # This adds the allauth urls to the project: # - accounts/keycloak/login/ # - accounts/keycloak/login/callback/ path("accounts/", include("allauth.urls")), - path("accounts/profile/", views.ProfileView.as_view(), name="profile"), - path("admin/", admin.site.urls), - path("", views.IndexView.as_view(), name="index"), ] # Serve static and media files in development