accrete 0.0.149__py3-none-any.whl → 0.0.151__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. accrete/contrib/log/queries.py +3 -1
  2. accrete/contrib/ui/admin.py +9 -1
  3. accrete/contrib/ui/forms.py +57 -0
  4. accrete/contrib/ui/migrations/0001_initial.py +39 -0
  5. accrete/contrib/ui/migrations/0002_alter_theme_color_danger_alter_theme_color_link_and_more.py +38 -0
  6. accrete/contrib/ui/migrations/0003_alter_theme_check_user_or_tenant.py +21 -0
  7. accrete/contrib/ui/migrations/0004_theme_force_tenant_theme.py +18 -0
  8. accrete/contrib/ui/models.py +115 -1
  9. accrete/contrib/ui/response.py +14 -5
  10. accrete/contrib/ui/static/css/accrete.css +23 -57
  11. accrete/contrib/ui/static/css/accrete.css.map +1 -1
  12. accrete/contrib/ui/static/css/accrete.scss +76 -55
  13. accrete/contrib/ui/templates/django/forms/widgets/input.html +1 -1
  14. accrete/contrib/ui/templates/ui/custom_theme.html +19 -0
  15. accrete/contrib/ui/templates/ui/layout.html +9 -9
  16. accrete/contrib/ui/templates/ui/list.html +2 -2
  17. accrete/contrib/ui/templates/ui/message.html +2 -2
  18. accrete/contrib/ui/templates/ui/modal.html +3 -3
  19. accrete/contrib/ui/templates/ui/table.html +5 -5
  20. accrete/contrib/ui/templates/ui/templatetags/field.html +50 -11
  21. accrete/contrib/ui/templates/ui/widgets/date_weekday.html +10 -0
  22. accrete/contrib/ui/templates/ui/widgets/model_search_select.html +4 -3
  23. accrete/contrib/ui/templates/ui/widgets/model_search_select_multi.html +5 -4
  24. accrete/contrib/ui/templatetags/ui.py +37 -5
  25. accrete/contrib/ui/views.py +90 -2
  26. accrete/contrib/ui/widgets/__init__.py +1 -0
  27. accrete/contrib/ui/widgets/date_weekday.py +6 -0
  28. accrete/contrib/ui/widgets/search_select.py +2 -2
  29. accrete/contrib/user/forms.py +1 -1
  30. accrete/contrib/user/migrations/0009_alter_user_theme.py +18 -0
  31. accrete/contrib/user/migrations/0010_alter_user_theme.py +18 -0
  32. accrete/contrib/user/models.py +5 -3
  33. accrete/contrib/user/templates/user/login.html +3 -11
  34. accrete/contrib/user/templates/user/user_preferences.html +27 -15
  35. accrete/contrib/user/views.py +7 -2
  36. accrete/fields.py +4 -2
  37. accrete/managers.py +9 -0
  38. accrete/migrations/0009_alter_accessgroup_name.py +30 -0
  39. accrete/models.py +6 -4
  40. accrete/views.py +32 -20
  41. {accrete-0.0.149.dist-info → accrete-0.0.151.dist-info}/METADATA +1 -1
  42. {accrete-0.0.149.dist-info → accrete-0.0.151.dist-info}/RECORD +44 -33
  43. {accrete-0.0.149.dist-info → accrete-0.0.151.dist-info}/WHEEL +0 -0
  44. {accrete-0.0.149.dist-info → accrete-0.0.151.dist-info}/licenses/LICENSE +0 -0
@@ -1,9 +1,97 @@
1
1
  import json
2
-
3
2
  from django.contrib.auth.decorators import login_required
3
+ from django.views.generic import View
4
+ from django.utils.translation import gettext_lazy as _
4
5
  from django.apps import apps
5
6
  from django.http import HttpResponse
6
- from .filter import Filter
7
+ from accrete.views import TenantRequiredMixin
8
+ from accrete.models import AccessGroup
9
+ from accrete.contrib.ui.filter import Filter
10
+ from accrete.contrib.ui.response import WindowResponse, ModalResponse
11
+
12
+
13
+ class TenantView(TenantRequiredMixin, View):
14
+
15
+ """
16
+ Base View that handles displaying access denied messages
17
+ to the user if member/tenant groups are missing.
18
+ """
19
+
20
+ def handle_tenant_group_not_set(self):
21
+ if not self._is_htmx():
22
+ return self._access_denied_page_response()
23
+ return self._access_denied_modal_response()
24
+
25
+ def handle_member_group_not_set(self):
26
+ if not self._is_htmx():
27
+ return self._access_denied_page_response()
28
+ return self._access_denied_modal_response()
29
+
30
+ def _access_denied_page_response(self):
31
+ return WindowResponse(
32
+ title=str(_('Access Denied')),
33
+ overview_template='mirox/base/group_not_set.html',
34
+ context=dict(groups=self._get_group_data()),
35
+ is_centered=True
36
+ ).response(self.request)
37
+
38
+ def _access_denied_modal_response(self):
39
+ res = ModalResponse(
40
+ template='mirox/base/group_not_set_modal.html',
41
+ title=str(_('Access Denied')),
42
+ modal_id='group-missing-modal',
43
+ context=dict(groups=self._get_group_data())
44
+ ).response(self.request)
45
+ res.headers['HX-Reswap'] = 'none'
46
+ res.headers['HX-Push-Url'] = 'false'
47
+ return res
48
+
49
+ def _get_group_data(self) -> dict:
50
+ data = {}
51
+ tenant_groups, member_groups = self._flat_groups()
52
+ if tenant_groups:
53
+ data.update(tenant_groups=[])
54
+ access_groups = AccessGroup.objects.filter(
55
+ code__in=tenant_groups,
56
+ apply_on='tenant'
57
+ ).all()
58
+ group_data = {item[0]: item[1] for item in access_groups.values_list('code', 'name')}
59
+ for group in self.TENANT_GROUPS:
60
+ if isinstance(group, tuple):
61
+ data['tenant_groups'].append(' & '.join([group_data.get(g, g) for g in group]))
62
+ else:
63
+ data['tenant_groups'].append(group_data.get(group, group))
64
+ if member_groups:
65
+ data.update(member_groups=[])
66
+ access_groups = AccessGroup.objects.filter(
67
+ code__in=member_groups,
68
+ apply_on='member'
69
+ ).all()
70
+ group_data = {item[0]: item[1] for item in access_groups.values_list('code', 'name')}
71
+ for group in self.MEMBER_GROUPS:
72
+ if isinstance(group, tuple):
73
+ data['member_groups'].append(' & '.join([group_data.get(g, g) for g in group]))
74
+ else:
75
+ data['member_groups'].append(group_data.get(group, group))
76
+ return data
77
+
78
+ def _flat_groups(self) -> tuple[list[str], list[str]]:
79
+ def group_list(g):
80
+ if isinstance(g, str):
81
+ return [g]
82
+ elif isinstance(g, tuple):
83
+ return [x for x in g]
84
+ return []
85
+ tenant_groups = []
86
+ member_groups = []
87
+ for group in self.TENANT_GROUPS:
88
+ tenant_groups.extend(group_list(group))
89
+ for group in self.MEMBER_GROUPS:
90
+ member_groups.extend(group_list(group))
91
+ return tenant_groups, member_groups
92
+
93
+ def _is_htmx(self):
94
+ return self.request.headers.get('HX-Request', 'false') == 'true'
7
95
 
8
96
 
9
97
  @login_required
@@ -1 +1,2 @@
1
1
  from .search_select import ModelSearchSelect, ModelSearchSelectMulti
2
+ from. date_weekday import DateWeekday
@@ -0,0 +1,6 @@
1
+ from django.forms import widgets
2
+
3
+
4
+ class DateWeekday(widgets.DateInput):
5
+ template_name = 'ui/widgets/date_weekday.html'
6
+ input_type = 'date'
@@ -22,7 +22,7 @@ class ModelSearchSelect(widgets.NumberInput):
22
22
  self.search_kwargs = search_kwargs or {}
23
23
  self.search_parameter = search_parameter
24
24
  self.limit = limit
25
- self.hx_trigger_on_change = hx_trigger_on_change # trigger 'change' event on the actual input field
25
+ self.hx_trigger_on_change = hx_trigger_on_change # trigger htmx request on change
26
26
  self.choices = choices
27
27
 
28
28
  def get_context(self, name, value, attrs):
@@ -82,7 +82,7 @@ class ModelSearchSelectMulti(widgets.SelectMultiple):
82
82
  self.search_kwargs = search_kwargs or {}
83
83
  self.search_parameter = search_parameter
84
84
  self.limit = limit
85
- self.hx_trigger_on_change = hx_trigger_on_change # trigger 'change' event on the actual input field
85
+ self.hx_trigger_on_change = hx_trigger_on_change # trigger 'change' event on the actual input field
86
86
  self.choices = choices
87
87
 
88
88
  def get_context(self, name, value, attrs):
@@ -24,7 +24,7 @@ class UserForm(ModelForm):
24
24
  ]
25
25
 
26
26
  widgets = {
27
- 'theme': forms.Select(
27
+ 'theme': forms.RadioSelect(
28
28
  attrs={'autocomplete': 'off'}
29
29
  )
30
30
  }
@@ -0,0 +1,18 @@
1
+ # Generated by Django 5.2.1 on 2025-08-07 16:34
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('user', '0008_remove_user_no_email_for_managed_user_and_more'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name='user',
15
+ name='theme',
16
+ field=models.CharField(choices=[('light', 'Light'), ('dark', 'Dark'), ('custom', 'Custom')], default='light', max_length=50, verbose_name='Theme'),
17
+ ),
18
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 5.2.1 on 2025-08-08 06:13
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('user', '0009_alter_user_theme'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name='user',
15
+ name='theme',
16
+ field=models.CharField(choices=[('preset', 'Preset'), ('light', 'Light'), ('dark', 'Dark'), ('custom', 'Custom')], default='preset', max_length=50, verbose_name='Theme'),
17
+ ),
18
+ ]
@@ -181,10 +181,12 @@ class User(AbstractBaseUser, PermissionsMixin):
181
181
  verbose_name=_('Theme'),
182
182
  max_length=50,
183
183
  choices=[
184
- ('light', 'Light'),
185
- ('dark', 'Dark')
184
+ ('preset', _('Preset')),
185
+ ('light', _('Light')),
186
+ ('dark', _('Dark')),
187
+ ('custom', _('Custom'))
186
188
  ],
187
- default='light'
189
+ default='preset'
188
190
  )
189
191
 
190
192
  objects = UserManager()
@@ -1,6 +1,7 @@
1
1
  {% extends 'ui/layout.html' %}
2
2
  {% load static %}
3
3
  {% load i18n %}
4
+ {% load ui %}
4
5
 
5
6
  {% block title %}Login{% endblock %}
6
7
  {% block theme %}data-theme="light"{% endblock %}
@@ -30,17 +31,8 @@
30
31
  <div class="column box is-8-tablet is-offset-2-tablet is-6-desktop is-offset-3-desktop is-4-fullhd is-offset-4-fullhd p-3" data-theme="light">
31
32
  <form method="POST" action="" hx-boost="false">
32
33
  {% csrf_token %}
33
- <span class="has-text-danger is-size-7">{{ form.non_field_errors }}</span>
34
- <label class="label">
35
- <span class="has-text-black">{{ form.username.label_tag }}</span>
36
- {{ form.username }}
37
- <span class="has-text-danger is-size-7">{{ form.username.errors }}</span>
38
- </label>
39
- <label class="label">
40
- <span class="has-text-black">{{ form.password.label_tag }}</span>
41
- {{ form.password }}
42
- <span class="has-text-danger is-size-7">{{ form.password.errors }}</span>
43
- </label>
34
+ {{ form.username|wrap_form_field }}
35
+ {{ form.password|wrap_form_field }}
44
36
  <input type="submit" class="button is-fullwidth is-success is-medium"
45
37
  value="{% translate 'Login' %}">
46
38
  </form>
@@ -15,29 +15,41 @@
15
15
  {% endpartialdef %}
16
16
 
17
17
  {% partialdef data %}
18
- <section class="box">
18
+ <section class="box" x-data="{themeType: '{{ form.theme.value }}'}">
19
19
  <form id="user-form" hx-post="{% url 'user:detail' %}" hx-trigger="change changed" hx-select="#user-form" hx-swap="outerHTML" hx-select-oob="#navbar-user-name">
20
20
  {% csrf_token %}
21
21
  <div class="columns">
22
22
  <div class="column">
23
23
  {% include 'ui/form_error.html' %}
24
+ {% include 'ui/form_error.html' with form=theme_form %}
24
25
  </div>
25
26
  </div>
26
- <div class="columns">
27
- <div class="column is-6">
28
- {% translate 'Username' as username %}
29
- {{ form.username|wrap_form_field:username }}
30
- {{ form.first_name|wrap_form_field }}
31
- {{ form.last_name|wrap_form_field }}
32
- </div>
33
- <div class="column is-6">
34
- {% if not user.is_managed %}
35
- {{ user|wrap_model_field:'email' }}
36
- {% endif %}
37
- {{ form.language_code|wrap_form_field }}
38
- {{ form.theme|wrap_form_field }}
39
- </div>
27
+ <div class="columns is-multiline">
28
+ <div class="column is-6">{{ form.username|wrap_form_field }}</div>
29
+ {% if not user.is_managed %}
30
+ <div class="column is-6">{{ user|wrap_model_field:'email' }}</div>
31
+ {% else %}
32
+ <div class="column is-6"></div>
33
+ {% endif %}
34
+ <div class="column is-6">{{ form.first_name|wrap_form_field }}</div>
35
+ <div class="column is-6">{{ form.last_name|wrap_form_field }}</div>
36
+ <div class="column is-6">{{ form.language_code|wrap_form_field }}</div>
37
+ <div class="column is-6">{{ form.theme|wrap_form_field }}</div>
40
38
  </div>
41
39
  </form>
40
+ <div id="theme" x-show="['custom'].includes(themeType)">
41
+ <p class="title is-size-5 mb-0 mt-4">{% translate 'Custom Theme' %}</p>
42
+ <hr class="mt-1"/>
43
+ <form id="theme-form" hx-post="{% url 'user:detail' %}" hx-trigger="change changed" hx-select="#theme-form" hx-swap="outerHTML">
44
+ {{ theme_form.user }}
45
+ <div class="columns is-multiline">
46
+ <div class="column">{{ theme_form.color_primary|wrap_form_field }}</div>
47
+ <div class="column">{{ theme_form.color_success|wrap_form_field }}</div>
48
+ <div class="column">{{ theme_form.color_link|wrap_form_field }}</div>
49
+ <div class="column">{{ theme_form.color_warning|wrap_form_field }}</div>
50
+ <div class="column">{{ theme_form.color_danger|wrap_form_field }}</div>
51
+ </div>
52
+ </form>
53
+ </div>
42
54
  </section>
43
55
  {% endpartialdef %}
@@ -8,6 +8,8 @@ from django.conf import settings
8
8
 
9
9
  from accrete.utils import save_form
10
10
  from accrete.contrib import ui
11
+ from accrete.contrib.ui.models import Theme
12
+ from accrete.contrib.ui.forms import ThemeForm
11
13
  from .forms import UserForm, ChangePasswordForm, ChangeEmailForm
12
14
 
13
15
 
@@ -40,16 +42,19 @@ def user_detail(request):
40
42
  initial={'language_code': request.user.language_code},
41
43
  instance=request.user
42
44
  )
45
+ theme = Theme.objects.filter(user=request.user).first()
46
+ theme_form = ThemeForm(instance=theme, prefix='theme', initial={'user': request.user})
43
47
  refresh = False
44
48
  if request.method == 'POST':
45
49
  form = save_form(UserForm(request.POST, instance=request.user))
46
- if form.is_saved:
50
+ theme_form = save_form(ThemeForm(request.POST, instance=theme, prefix='theme', initial={'user': request.user}))
51
+ if (form.is_saved or not form.has_changed()) or (theme_form.is_saved or not theme_form.has_changed()):
47
52
  refresh = True
48
53
  res = ui.WindowResponse(
49
54
  title=str(_('User Preferences')),
50
55
  overview_template='user/user_preferences.html#data',
51
56
  header_template='user/user_preferences.html#header',
52
- context=dict(user=request.user, form=form),
57
+ context=dict(user=request.user, form=form, theme_form=theme_form),
53
58
  is_centered=True
54
59
  ).response(request, replace_body=False)
55
60
  if refresh:
accrete/fields.py CHANGED
@@ -49,8 +49,10 @@ class TranslatedCharField(JSONField):
49
49
  if new_val in [None, False, '']:
50
50
  return {self.default_language: ''}
51
51
  if isinstance(new_val, dict):
52
- new_val = new_val.get(language, '')
53
- old_val.update({language: new_val})
52
+ old_val.update(new_val)
53
+ # new_val = new_val.get(language, '')
54
+ else:
55
+ old_val.update({language: new_val})
54
56
  if language != self.default_language and not old_val.get(self.default_language):
55
57
  old_val.update({self.default_language: new_val})
56
58
  return old_val
accrete/managers.py CHANGED
@@ -47,3 +47,12 @@ class MemberManager(TenantManager):
47
47
  def get_queryset(self):
48
48
  queryset = super().get_queryset().select_related('tenant', 'user')
49
49
  return queryset
50
+
51
+
52
+ class AccessGroupManager(models.Manager):
53
+
54
+ def tenant_groups(self):
55
+ return self.get_queryset().filter(apply_on='tenant')
56
+
57
+ def member_groups(self):
58
+ return self.get_queryset().filter(apply_on='member')
@@ -0,0 +1,30 @@
1
+ # Generated by Django 5.2.1 on 2025-07-31 05:12
2
+
3
+ import json
4
+ import accrete.fields
5
+ from django.db import migrations
6
+ from django.conf import settings
7
+
8
+
9
+ def char_to_translated_char(apps, schema):
10
+ AccessGroup = apps.get_model('accrete', 'AccessGroup')
11
+ default_lang = getattr(settings, 'LANGUAGE_CODE', 'en-us')
12
+ for group in AccessGroup.objects.all():
13
+ group.name = json.dumps({default_lang: group.name})
14
+ group.save()
15
+
16
+
17
+ class Migration(migrations.Migration):
18
+
19
+ dependencies = [
20
+ ('accrete', '0008_alter_member_access_groups_and_more'),
21
+ ]
22
+
23
+ operations = [
24
+ migrations.RunPython(char_to_translated_char),
25
+ migrations.AlterField(
26
+ model_name='accessgroup',
27
+ name='name',
28
+ field=accrete.fields.TranslatedCharField(verbose_name='Name'),
29
+ ),
30
+ ]
accrete/models.py CHANGED
@@ -3,7 +3,8 @@ from django.db import models
3
3
  from django.conf import settings
4
4
  from django.utils.translation import gettext_lazy as _
5
5
  from accrete.tenant import get_tenant
6
- from accrete.managers import TenantManager, MemberManager
6
+ from accrete.managers import TenantManager, MemberManager, AccessGroupManager
7
+ from accrete.fields import TranslatedCharField
7
8
 
8
9
 
9
10
  class TenantModel(models.Model):
@@ -131,9 +132,10 @@ class AccessGroup(models.Model):
131
132
  )
132
133
  ]
133
134
 
134
- name = models.CharField(
135
- verbose_name=_('Name'),
136
- max_length=255
135
+ objects = AccessGroupManager()
136
+
137
+ name = TranslatedCharField(
138
+ verbose_name=_('Name')
137
139
  )
138
140
 
139
141
  description = models.TextField(
accrete/views.py CHANGED
@@ -1,6 +1,8 @@
1
1
  import os
2
2
  from functools import wraps
3
- from django.http import HttpResponse, HttpResponseNotFound
3
+ from typing import Callable
4
+
5
+ from django.http import HttpResponse, HttpResponseNotFound, HttpResponseForbidden, HttpRequest
4
6
  from django.contrib.auth.mixins import LoginRequiredMixin
5
7
  from django.contrib.auth.views import login_required
6
8
  from django.core.exceptions import ImproperlyConfigured
@@ -104,6 +106,9 @@ class TenantRequiredMixin(LoginRequiredMixin):
104
106
  def tenant_required(
105
107
  tenant_groups: list[str | tuple[str]] = None,
106
108
  member_groups: list[str | tuple[str]] = None,
109
+ group_missing_action: str | Callable[[
110
+ HttpRequest, list[str | tuple[str]], list[str | tuple[str]]
111
+ ], HttpResponse] = None,
107
112
  redirect_field_name: str = None,
108
113
  login_url: str = None
109
114
  ):
@@ -114,30 +119,37 @@ def tenant_required(
114
119
  login_url=login_url
115
120
  )
116
121
  def _wrapped_view(request, *args, **kwargs):
122
+
123
+ def handle_group_missing():
124
+ if callable(group_missing_action):
125
+ return group_missing_action(
126
+ request, tenant_groups, member_groups
127
+ )
128
+ return (
129
+ redirect(config.ACCRETE_GROUP_NOT_SET_URL)
130
+ if config.ACCRETE_GROUP_NOT_SET_URL
131
+ else HttpResponseForbidden()
132
+ )
133
+
117
134
  tenant = request.tenant
118
135
  if not tenant:
119
136
  return redirect(config.ACCRETE_TENANT_NOT_SET_URL)
120
- redirect_url = None
121
137
  for tenant_group in (tenant_groups or []):
122
- if (
123
- (isinstance(tenant_group, tuple) and all([
124
- tenant_has_group(g) for g in tenant_group
125
- ]))
126
- or tenant_has_group(tenant_group)
127
- ):
128
- return f(request, *args, **kwargs)
129
- redirect_url = config.ACCRETE_GROUP_NOT_SET_URL
138
+ if isinstance(tenant_group, tuple) and all([
139
+ tenant_has_group(g) for g in tenant_group
140
+ ]):
141
+ break
142
+ elif isinstance(tenant_group, str) and tenant_has_group(tenant_group):
143
+ break
144
+ return handle_group_missing()
130
145
  for member_group in (member_groups or []):
131
- if (
132
- (isinstance(member_group, tuple) and all([
133
- member_has_group(g) for g in member_group
134
- ]))
135
- or member_has_group(member_group)
136
- ):
137
- return f(request, *args, **kwargs)
138
- redirect_url = config.ACCRETE_GROUP_NOT_SET_URL
139
- if redirect_url:
140
- return redirect(redirect_url)
146
+ if isinstance(member_group, tuple) and all([
147
+ member_has_group(g) for g in member_group
148
+ ]):
149
+ break
150
+ elif isinstance(member_group, str) and member_has_group(member_group):
151
+ break
152
+ return handle_group_missing()
141
153
  return f(request, *args, **kwargs)
142
154
  return _wrapped_view
143
155
  return decorator
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: accrete
3
- Version: 0.0.149
3
+ Version: 0.0.151
4
4
  Summary: Django Shared Schema Multi Tenant
5
5
  Author-email: Benedikt Jilek <benedikt.jilek@pm.me>
6
6
  License: Copyright (c) 2025 Benedikt Jilek