From a6acbe1eb54c229674120020934a97185858aac8 Mon Sep 17 00:00:00 2001 From: Pablo Schmeiser Date: Mon, 2 Mar 2026 04:34:47 +0100 Subject: [PATCH 1/4] Adding, correcting or fixing form validation --- src/shiftings/accounts/forms/user_form.py | 27 ++++++++++++++++++----- src/shiftings/mail/forms/mail.py | 24 ++++++++++++++++++++ src/shiftings/mail/settings.py | 2 ++ src/shiftings/shifts/forms/shift.py | 2 +- 4 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 src/shiftings/mail/settings.py diff --git a/src/shiftings/accounts/forms/user_form.py b/src/shiftings/accounts/forms/user_form.py index 7a4dc98..a30c7c9 100644 --- a/src/shiftings/accounts/forms/user_form.py +++ b/src/shiftings/accounts/forms/user_form.py @@ -1,9 +1,11 @@ -from typing import Any, Dict +from typing import Any, Optional from django import forms +from django.contrib.auth.password_validation import get_password_validators, validate_password from django.utils.translation import gettext_lazy as _ from shiftings.accounts.models import User +from shiftings.settings import AUTH_PASSWORD_VALIDATORS class UserCreateForm(forms.ModelForm): @@ -26,11 +28,16 @@ def clean(self) -> Dict[str, Any]: password = cleaned_data['password'] confirm_password = cleaned_data['confirm_password'] if password != confirm_password: - raise forms.ValidationError( - _('Please enter matching passwords') - ) - return cleaned_data + self.add_error('password', _('Please enter matching passwords')) + self.add_error('confirm_password', _('Please enter matching passwords')) + + # validate password using Django's built-in validators + try: + validate_password(password, user=None, password_validators=get_password_validators(AUTH_PASSWORD_VALIDATORS)) + except forms.ValidationError as e: + self.add_error('password', e) + return cleaned_data class UserUpdateForm(forms.ModelForm): class Meta: @@ -47,3 +54,13 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.fields['first_name'].disabled = True self.fields['last_name'].disabled = True self.fields['email'].disabled = True + + def clean(self) -> Optional[dict[str, Any]]: + cleaned_data = super().clean() + if hasattr(self.instance, 'ldap_user'): + # if this is an ldap user, ensure that the fields are not changed + if (cleaned_data.get('first_name') != self.instance.first_name or + cleaned_data.get('last_name') != self.instance.last_name or + cleaned_data.get('email') != self.instance.email): + raise forms.ValidationError(_('Cannot change first name, last name or email for LDAP users.')) + return cleaned_data diff --git a/src/shiftings/mail/forms/mail.py b/src/shiftings/mail/forms/mail.py index aee8d46..931a35d 100644 --- a/src/shiftings/mail/forms/mail.py +++ b/src/shiftings/mail/forms/mail.py @@ -1,6 +1,9 @@ +from typing import Any, Optional + from django import forms from django.utils.translation import gettext_lazy as _ +from shiftings.mail.settings import MAX_ATTACHMENT_SIZE_MB, MAX_TOTAL_ATTACHMENT_SIZE_MB from shiftings.organizations.models import MembershipType, Organization from shiftings.shifts.models import ShiftType from shiftings.utils.fields.date_time import DateTimeFormField @@ -14,6 +17,19 @@ class MailForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['attachments'].widget.attrs['multiple'] = True + + def clean(self) -> dict[str, Any]: + cleaned_data = super().clean() + attachments = self.files.getlist('attachments') + for attachment in attachments: + if attachment.size > MAX_ATTACHMENT_SIZE_MB * 1024 * 1024: # Convert MB to bytes + self.add_error('attachments', _('Each attachment must be smaller than 10 MB.')) + break + + if sum(attachment.size for attachment in attachments) > MAX_TOTAL_ATTACHMENT_SIZE_MB * 1024 * 1024: + self.add_error('attachments', _('Total attachment size must be smaller than 25 MB.')) + + return cleaned_data class OrganizationMailForm(MailForm): @@ -46,3 +62,11 @@ def __init__(self, organization: Organization, *args, **kwargs): self.organization = organization self.fields['shift_types'].queryset = organization.shift_types + + def clean(self) -> Optional[dict[str, Any]]: + cleaned_data = super().clean() + start = cleaned_data.get('start') + end = cleaned_data.get('end') + if start and end and start > end: + raise forms.ValidationError(_('Start time must be before end time.')) + return cleaned_data \ No newline at end of file diff --git a/src/shiftings/mail/settings.py b/src/shiftings/mail/settings.py new file mode 100644 index 0000000..6c4d81a --- /dev/null +++ b/src/shiftings/mail/settings.py @@ -0,0 +1,2 @@ +MAX_ATTACHMENT_SIZE_MB : int = 10 +MAX_TOTAL_ATTACHMENT_SIZE_MB : int = 25 \ No newline at end of file diff --git a/src/shiftings/shifts/forms/shift.py b/src/shiftings/shifts/forms/shift.py index 7a36230..147e851 100644 --- a/src/shiftings/shifts/forms/shift.py +++ b/src/shiftings/shifts/forms/shift.py @@ -36,7 +36,7 @@ def clean(self) -> Dict[str, Any]: start = cleaned_data.get('start') end = cleaned_data.get('end') if start and end and start > end: - self.add_error('end', ValidationError(_('End time must be after start time'))) + raise ValidationError(_('End time must be after start time')) ## TODO: raise form error if not valid, but first implement proper error display in template max_length = timedelta(minutes=settings.MAX_SHIFT_LENGTH_MINUTES) From a7fedfff91551bcfb396997a571c40dce789017a Mon Sep 17 00:00:00 2001 From: Pablo Schmeiser Date: Mon, 2 Mar 2026 04:35:56 +0100 Subject: [PATCH 2/4] Adding form tests --- .../accounts/tests/test_user_forms.py | 75 +++++++++++++++++++ .../organizations/tests/test_forms.py | 66 ++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/shiftings/accounts/tests/test_user_forms.py create mode 100644 src/shiftings/organizations/tests/test_forms.py diff --git a/src/shiftings/accounts/tests/test_user_forms.py b/src/shiftings/accounts/tests/test_user_forms.py new file mode 100644 index 0000000..65cf1cb --- /dev/null +++ b/src/shiftings/accounts/tests/test_user_forms.py @@ -0,0 +1,75 @@ +from django.test import SimpleTestCase + +from shiftings.accounts.forms.user_form import UserCreateForm, UserUpdateForm +from shiftings.accounts.models import User + + +class UserFormTests(SimpleTestCase): + def test_user_create_password_mismatch(self): + data = { + 'username': 'tester', + 'display_name': 'Tester', + 'first_name': 'Test', + 'last_name': 'User', + 'email': 'test@example.com', + 'phone_number': '12345', + 'password': 'secret1', + 'confirm_password': 'secret2', + } + form = UserCreateForm(data=data) + self.assertFalse(form.is_valid()) + self.assertIn('confirm_password', form.errors) + self.assertIn('password', form.errors) + + def test_user_create_valid(self): + data = { + 'username': 'tester', + 'display_name': 'Tester', + 'first_name': 'Test', + 'last_name': 'User', + 'email': 'test@example.com', + 'phone_number': '12345', + 'password': 'secret', + 'confirm_password': 'secret', + } + form = UserCreateForm(data=data) + self.assertTrue(form.is_valid()) + + def test_user_update_ldap_user(self): + user = User.objects.create(username='ldapuser', first_name='LDAP', last_name='User', email='ldap@example.com') + # simulate an ldap user on the instance + user.ldap_user = True + form = UserUpdateForm(instance=user) + self.assertTrue(form.fields['first_name'].disabled) + self.assertTrue(form.fields['last_name'].disabled) + self.assertTrue(form.fields['email'].disabled) + # try to change the first name, which should raise a validation error + form = UserUpdateForm(instance=user, data={ + 'first_name': 'Changed', + 'last_name': 'User', + 'email': 'changed@example.com', + }) + self.assertFalse(form.is_valid()) + self.assertIn('first_name', form.errors) + self.assertIn('last_name', form.errors) + self.assertIn('email', form.errors) + + def test_user_update_non_ldap_user(self): + user = User.objects.create(username='normaluser', first_name='Normal', last_name='User', email='normal@example.com') + form = UserUpdateForm(instance=user) + self.assertFalse(form.fields['first_name'].disabled) + self.assertFalse(form.fields['last_name'].disabled) + self.assertFalse(form.fields['email'].disabled) + # try to change the first name, which should be allowed + form = UserUpdateForm(instance=user, data={ + 'first_name': 'Changed', + 'last_name': 'User', + 'email': 'changed@example.com', + }) + self.assertTrue(form.is_valid()) + + # Ensure the form data is correctly updated + updated_user = form.save() + self.assertEqual(updated_user.first_name, 'Changed') + self.assertEqual(updated_user.last_name, 'User') + self.assertEqual(updated_user.email, 'changed@example.com') diff --git a/src/shiftings/organizations/tests/test_forms.py b/src/shiftings/organizations/tests/test_forms.py new file mode 100644 index 0000000..b0a0815 --- /dev/null +++ b/src/shiftings/organizations/tests/test_forms.py @@ -0,0 +1,66 @@ +from django import forms +from django.test import TestCase + +from django.contrib.auth.models import Group + +from shiftings.accounts.models import User +from shiftings.organizations.forms.membership import MembershipForm, MembershipTypeForm +from shiftings.organizations.forms.organization import OrganizationForm +from shiftings.organizations.models import Organization, MembershipType + + +class OrganizationFormTests(TestCase): + def test_fields_present(self): + form = OrganizationForm() + self.assertIn('name', form.fields) + self.assertIn('logo', form.fields) + self.assertIn('email', form.fields) + self.assertIn('description', form.fields) + + +class MembershipFormTests(TestCase): + def setUp(self): + self.org = Organization.objects.create(name='Test Org') + self.user = User.objects.create(username='alice', first_name='Alice', last_name='L', email='alice@example.com') + self.other_user = User.objects.create(username='bob', first_name='Bob', last_name='B', email='bob@example.com') + self.type = MembershipType.objects.create(organization=self.org, name='Member', admin=False) + self.group = Group.objects.create(name='Group1') + + def test_clean_user_not_found(self): + data = { + 'organization': self.org.pk, + 'type': self.type.pk, + 'user': 'nonexistent', + 'group': self.group.pk, + } + form = MembershipForm(self.user, data=data, initial={'organization': self.org}) + self.assertFalse(form.is_valid()) + self.assertIn('user', form.errors) + + def test_clean_user_found(self): + data = { + 'organization': self.org.pk, + 'type': self.type.pk, + 'user': self.other_user.username, + 'group': self.group.pk, + } + form = MembershipForm(self.user, data=data, initial={'organization': self.org}) + self.assertTrue(form.is_valid()) + self.assertIsInstance(form.cleaned_data['user'], User) + + +class MembershipTypeFormTests(TestCase): + def setUp(self): + self.org = Organization.objects.create(name='Org2') + + def test_permissions_hidden_when_not_admin(self): + instance = MembershipType.objects.create(organization=self.org, name='T1', admin=False) + form = MembershipTypeForm(is_admin=False, instance=instance) + self.assertIsInstance(form.fields['permissions'].widget, forms.HiddenInput) + self.assertTrue(form.fields['permissions'].disabled) + + def test_permissions_visible_when_admin_and_instance_not_admin(self): + instance = MembershipType.objects.create(organization=self.org, name='T2', admin=False) + form = MembershipTypeForm(is_admin=True, instance=instance) + self.assertFalse(isinstance(form.fields['permissions'].widget, forms.HiddenInput)) + self.assertFalse(form.fields['permissions'].disabled) From c51b040866a260e96770b4d376396ad4eaf2512b Mon Sep 17 00:00:00 2001 From: Pablo Schmeiser Date: Mon, 2 Mar 2026 04:39:15 +0100 Subject: [PATCH 3/4] Documenting information --- docs/events/index.md | 110 +++++++++++++++++++++- pyproject.toml | 70 +++++++++++++- src/shiftings/utils/context_processors.py | 1 + src/shiftings/utils/converters.py | 1 + 4 files changed, 179 insertions(+), 3 deletions(-) diff --git a/docs/events/index.md b/docs/events/index.md index b5f8f3e..8fc2ec8 100644 --- a/docs/events/index.md +++ b/docs/events/index.md @@ -1 +1,109 @@ -# Coming Soon™ \ No newline at end of file +# Events + +## Overview + +Events are high-level organizational containers that group related shifts together around a specific occasion, festival, conference, or multi-day program. An Event provides central coordination for multiple shifts, allowing organizations to manage comprehensive staffing operations for complex undertakings that span multiple days and require various shift types. + +Think of an Event as a parent container: while individual [Shifts](../shifts.md) represent specific time slots with particular work assignments, Events bundle these shifts together with unified contact information, branding, and staffing visibility. + +## Purpose of Events + +Events serve several key functions in the Shiftings system: + +### 1. Shift Organization +Events group logically related shifts, making it easier for both coordinators to manage schedules and volunteers to understand the scope of an opportunity. Rather than viewing dozens of isolated shifts, volunteers see them as part of a cohesive event. + +### 2. Event-Level Contact & Branding +Each Event can have its own: +- **Official name** and description +- **Logo and branding** +- **Contact information**: email address, phone number, website +- **Date range**: defining when shifts are available + +This allows large, complex events to maintain consistent contact information across all their shifts, and enables volunteers to find event-specific resources and communication channels. + +### 3. Staffing Overview +Events provide aggregate metrics for coordinators to monitor overall staffing: +- **Shifts needing more participants** (by required user count) +- **Open shifts available** for signup +- **Filled slots vs. needed slots** across all event shifts +- Visibility into overall staffing health at a glance + +### 4. Unified Participation Management +Organizations can set participation permissions at the Event level, which all contained shifts inherit. This simplifies permission management when you want the same participation rules across an entire event. + +## Key Event Attributes + +| Attribute | Type | Purpose | +|-----------|------|---------| +| **Name** | Text | Display name of the event (required) | +| **Organization** | Foreign Key | The organization that owns the event (required) | +| **Start Date** | Date | Earliest date where shifts are available (helps with filtering) | +| **End Date** | Date | Latest date where shifts are available | +| **Description** | Text | Detailed information about the event (optional) | +| **Logo** | Image | Event branding (optional; auto-resized to maximum size) | +| **Email** | Email | Primary contact email for event questions | +| **Telephone Number** | Phone | Contact phone number | +| **Website** | URL | Link to event website or more information | + +### Date Constraints +- Start Date must be less than or equal to End Date +- These dates help volunteers and coordinators quickly identify which events are currently active or when an event is scheduled + +## Shifts and Events + +Every shift can optionally be associated with an Event. When a shift references an event: + +- The **shift's email address** defaults to the event's email (if set), otherwise uses the organization's email +- The shift **inherits participation permissions** from the event (in addition to organization-level permissions) +- The shift appears in the event's shift list and contributes to event staffing metrics + +A shift being associated with an Event is **optional**—organizations can also create standalone shifts that aren't part of any formal event. + +### Validation +Shiftings enforces consistency: if a shift is linked to an event, both **must belong to the same organization**. This prevents accidental misalignment between event and shift ownership. + +## Participation and Permissions + +Events support participation permission settings that cascade to contained shifts. Organizations can granularly control: + +- **Who can see the event exists** +- **Who can see shift details** (dates, descriptions, participant names) +- **Who can view participant lists** +- **Who can participate in shifts** + +These permissions work alongside organization-level and shift-specific permissions, allowing flexible access control from the broadest (organization) to most specific (individual shift) level. + +## Example Use Case + +Imagine organizing a **two-day music festival**: + +**Event:** "Heimfest 2026" (May 29-31) +- Logo: Festival branding image +- Email: helfen-heimfest@hadiko.de +- Website: hadiko.de +- Description: "Help us create an amazing experience!" + +**Associated Shifts:** +- "Security Team - Friday 10am-6pm" (May 29) +- "Ticket Booth - Saturday 9am-5pm" (May 30) +- "Beer Sales - Saturday 9am-12pm" (May 30) +- "Cleanup Crew - Sunday 11am-4pm" (May 31) +- "Catering Support - Friday 4pm-11pm" (May 29) + +With this structure: +- Volunteers see the festival as one opportunity, not five isolated shifts +- All shifts share the festival's contact info and logo +- Coordinators can instantly see that 12 people are needed for Friday shifts, but only 8 have signed up +- Participation rules (e.g., volunteers must be 18+) can be set once at the event level +- The festival runs from May 29-31, and any shifts outside this range would be considered anomalies + +## Display and Access + +Events appear in organization interfaces and can be filtered by date range. The event's display format shows: + +``` +{Event Name} (by {Organization Name}) +``` + +This grouping makes it immediately clear which organization is running each event, useful in multi-organization environments. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8d05d23..ec897c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,16 +4,31 @@ version = '0.4.0' description = 'Simple shift management system' readme = 'README.md' requires-python = '>=3.12' +license = { text = 'MIT' } authors = [ { name = 'HaDiKo e. V. - HaDiNet', email = 'software@hadiko.de' }, ] +keywords = [ + 'shift', + 'management', + 'scheduling', + 'django', + 'calendar', +] classifiers = [ + 'Framework :: Django', + 'Framework :: Django :: 6.0', + 'Intended Audience :: End Users/Desktop', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.14', + 'Topic :: Office/Business', + 'Typing :: Typed', ] dependencies = [ @@ -34,23 +49,74 @@ dependencies = [ ] [project.optional-dependencies] -tests = [ +dev = [ 'django-stubs==5.2.9', 'types-python-dateutil==2.9.0.20260124', 'types-requests==2.32.4.20260107', 'mypy==1.19.1', ] +tests = [ + 'pytest==8.3.6', + 'pytest-django==4.9.1', + 'pytest-cov==6.0.0', +] + docs = [ 'mkdocs==1.6.1', 'mkdocs-material==9.7.1', ] [project.urls] -repository = 'https://github.com/HaDiNet/shiftings' +Homepage = 'https://github.com/HaDiNet/shiftings' +Repository = 'https://github.com/HaDiNet/shiftings' +'Bug Tracker' = 'https://github.com/HaDiNet/shiftings/issues' +Changelog = 'https://github.com/HaDiNet/shiftings/releases' [tool.setuptools.packages.find] where = ['src'] [tool.setuptools.package-data] shiftings = ['py.typed'] + +[tool.mypy] +python_version = '3.12' +warn_return_any = true +warn_unused_configs = true +check_untyped_defs = true +disallow_untyped_defs = false +plugins = ['mypy_django_plugin.main'] + +[tool.django-stubs] +django_settings_module = 'shiftings.settings' + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = 'shiftings.settings' +python_files = ['tests.py', 'test_*.py', '*_tests.py'] +testpaths = ['src'] +addopts = '--tb=short --strict-markers' +markers = [ + 'integration: marks tests as integration tests', + 'slow: marks tests as slow', +] + +[tool.coverage.run] +source = ['src/shiftings'] +omit = [ + '*/migrations/*', + '*/tests/*', + '*/test_*.py', + '*/__init__.py', +] + +[tool.coverage.report] +exclude_lines = [ + 'pragma: no cover', + 'def __repr__', + 'raise AssertionError', + 'raise NotImplementedError', + 'if __name__ == .__main__.:', + 'if TYPE_CHECKING:', + 'class .*\\bProtocol\\):', + '@(abc\\.)?abstractmethod', +] diff --git a/src/shiftings/utils/context_processors.py b/src/shiftings/utils/context_processors.py index e2ab8d9..29ca247 100644 --- a/src/shiftings/utils/context_processors.py +++ b/src/shiftings/utils/context_processors.py @@ -4,6 +4,7 @@ from django.conf import settings from django.http import HttpRequest +## FIXME: This is not used anywhere. Remove this if it is not needed. If this is needed, use it. def debug(request: HttpRequest) -> dict[str, Any]: return {'debug': settings.DEBUG} diff --git a/src/shiftings/utils/converters.py b/src/shiftings/utils/converters.py index 67554d8..59c148a 100644 --- a/src/shiftings/utils/converters.py +++ b/src/shiftings/utils/converters.py @@ -1,3 +1,4 @@ +## FIXME: This is not used anywhere. Remove it if it is not needed. If it is needed, use it. class AlphaNumericConverter: regex = r'[0-9A-Za-z]+' From ec3f9958f01243b4fac9c4ac9c17058f712f6cdc Mon Sep 17 00:00:00 2001 From: Pablo Schmeiser Date: Mon, 2 Mar 2026 15:52:32 +0100 Subject: [PATCH 4/4] Upgrading packages --- pyproject.toml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ec897c2..bc1171f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ 'django-ical==1.9.2', 'django-phonenumber-field[phonenumberslite]==8.4.0', 'gunicorn==25.1.0', - 'holidays==0.90', + 'holidays==0.91', 'icalendar==7.0.0', 'Pillow==12.1.1', 'psycopg2-binary==2.9.11', @@ -57,9 +57,9 @@ dev = [ ] tests = [ - 'pytest==8.3.6', + 'pytest==9.0.2', 'pytest-django==4.9.1', - 'pytest-cov==6.0.0', + 'pytest-cov==7.0.0', ] docs = [ @@ -73,12 +73,6 @@ Repository = 'https://github.com/HaDiNet/shiftings' 'Bug Tracker' = 'https://github.com/HaDiNet/shiftings/issues' Changelog = 'https://github.com/HaDiNet/shiftings/releases' -[tool.setuptools.packages.find] -where = ['src'] - -[tool.setuptools.package-data] -shiftings = ['py.typed'] - [tool.mypy] python_version = '3.12' warn_return_any = true