Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 109 additions & 1 deletion docs/events/index.md
Original file line number Diff line number Diff line change
@@ -1 +1,109 @@
# Coming Soon™
# 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.
74 changes: 67 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -25,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',
Expand All @@ -34,23 +49,68 @@ 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==9.0.2',
'pytest-django==4.9.1',
'pytest-cov==7.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.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.setuptools.packages.find]
where = ['src']
[tool.django-stubs]
django_settings_module = 'shiftings.settings'

[tool.setuptools.package-data]
shiftings = ['py.typed']
[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',
]
27 changes: 22 additions & 5 deletions src/shiftings/accounts/forms/user_form.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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:
Expand All @@ -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
75 changes: 75 additions & 0 deletions src/shiftings/accounts/tests/test_user_forms.py
Original file line number Diff line number Diff line change
@@ -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')
24 changes: 24 additions & 0 deletions src/shiftings/mail/forms/mail.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions src/shiftings/mail/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
MAX_ATTACHMENT_SIZE_MB : int = 10
MAX_TOTAL_ATTACHMENT_SIZE_MB : int = 25
Loading