aa-loa 0.0.1__tar.gz

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.
@@ -0,0 +1,17 @@
1
+ /build/
2
+ /dist/
3
+ /*.egg-info/
4
+ .idea/
5
+ .tox/
6
+ htmlcov/
7
+ node_modules/
8
+
9
+ *.log
10
+ *.tmp
11
+ *.bak
12
+ *.old
13
+ __pycache__
14
+ .coverage
15
+ /coverage.xml
16
+ alliance_auth.sqlite3
17
+ tmp_env/
aa_loa-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maddog Broekman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
aa_loa-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: aa-loa
3
+ Version: 0.0.1
4
+ Summary: Leave of Absence module for Alliance Auth
5
+ Project-URL: Changelog, https://github.com/mbroekman/aa-loa/blob/main/CHANGELOG.md
6
+ Project-URL: Homepage, https://github.com/mbroekman/aa-loa
7
+ Project-URL: Source, https://github.com/mbroekman/aa-loa
8
+ Project-URL: Tracker, https://github.com/mbroekman/aa-loa/issues
9
+ Author-email: Maddog Broekman <maddogbroekman@gmail.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: allianceauth,leave_of_absence,loa
13
+ Classifier: Environment :: Web Environment
14
+ Classifier: Framework :: Django
15
+ Classifier: Framework :: Django :: 4.2
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Topic :: Internet :: WWW/HTTP
25
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
26
+ Requires-Python: <3.14,>=3.10
27
+ Requires-Dist: allianceauth>=3.6.0
28
+ Requires-Dist: discord-webhook>=1.3.0
29
+ Description-Content-Type: text/markdown
30
+
31
+ # Alliance Auth - Leave of Absence (aa-loa)
32
+
33
+ [![PyPI version](https://img.shields.io/pypi/v/aa-loa)](https://pypi.org/project/aa-loa/)
34
+ [![Python versions](https://img.shields.io/pypi/pyversions/aa-loa)](https://pypi.org/project/aa-loa/)
35
+ [![Tests](https://github.com/mbroekman/aa-loa/actions/workflows/automated-checks.yml/badge.svg)](https://github.com/mbroekman/aa-loa/actions/workflows/automated-checks.yml)
36
+
37
+ A complete Leave of Absence (LOA) management module for Alliance Auth. This module allows members to declare periods of absence so that leadership is aware of their inactivity, while seamlessly integrating with other Alliance Auth features (like Discord and Activity Trackers) to prevent accidental purges.
38
+
39
+ ## Features
40
+
41
+ - **Player Dashboard:** Easy-to-use form for players to submit a start date, end date, and an optional reason. Players can also view their past LOAs, and cancel/revoke an active LOA if they return early.
42
+ - **HR Dashboard:** A dedicated view for Directors/HR to see a live table of all active and upcoming LOAs in the alliance.
43
+ - **Proxy Submission:** Directors can submit an LOA on behalf of a member who might be unable to access a PC (e.g. emergencies).
44
+ - **Discord Role Sync:** Assigns members to a specific Django Group (e.g., `[On Leave]`) when their LOA becomes active. Alliance Auth will automatically sync this to Discord, TS, or Mumble.
45
+ - **Purge Protection:** Because the member is in the `[On Leave]` group, audit modules (like `aa-inactives` or `opcalendar`) can simply whitelist this group to exempt the member from activity purges.
46
+ - **Webhooks:** Automatically posts a Discord Embed to a webhook URL whenever an LOA is submitted.
47
+ - **Welcome Back Notification:** A Celery task automatically removes the member from the LOA group when the end date passes, and sends a "Welcome Back" Alliance Auth notification.
48
+
49
+ ### 🤖 Optional: Discord Bot Integration (`aa-discordbot`)
50
+
51
+ While `aa-loa` works perfectly fine on its own, it integrates natively with [aa-discordbot](https://github.com/pvyParts/allianceauth-discordbot).
52
+ If your alliance has `aa-discordbot` installed, the internal Alliance Auth notifications generated by this module will automatically be forwarded as **Direct Messages (DMs)** on Discord to the user. This applies to:
53
+ - 📩 The "Welcome Back" notification when an LOA automatically expires.
54
+ - 🛑 The "Cancellation" notification when HR manually cancels a player's LOA.
55
+
56
+ ---
57
+
58
+ ## Installation
59
+
60
+ ### 1. Install the Python Package
61
+ Activate your Alliance Auth virtual environment and install the package:
62
+
63
+ ```bash
64
+ pip install -e /path/to/aa-loa/
65
+ ```
66
+
67
+ ### 2. Update Alliance Auth Settings
68
+ Open your `myauth/settings/local.py` file and add `'aa_loa'` to your `INSTALLED_APPS`:
69
+
70
+ ```python
71
+ INSTALLED_APPS += [
72
+ # ... other apps
73
+ 'aa_loa',
74
+ ]
75
+ ```
76
+
77
+ ### 3. Run Database Migrations
78
+ Run the migrations to create the database tables:
79
+
80
+ ```bash
81
+ python manage.py makemigrations aa_loa
82
+ python manage.py migrate
83
+ ```
84
+
85
+ ### 4. Restart Services
86
+ Restart your Alliance Auth web service and Celery workers so they pick up the new code and background tasks:
87
+
88
+ ```bash
89
+ sudo systemctl restart supervisor
90
+ ```
91
+ *(Or restart the specific services depending on your hosting environment).*
92
+
93
+ ---
94
+
95
+ ## Configuration
96
+
97
+ ### 1. Setup the LOA Group
98
+ 1. Go to the **Django Admin Panel** (`/admin/`).
99
+ 2. Navigate to **Authentication and Authorization** -> **Groups** and create a new group (e.g., `On Leave`).
100
+ - *Optional:* Configure this group in your Discord/TS services to map to a specific `[On Leave]` Discord Role.
101
+ 3. Navigate to **Leave of Absence Configuration** -> **LOA Config**.
102
+ 4. Add a new configuration row.
103
+ 5. Select the `On Leave` group you just created.
104
+ 6. *(Optional)* Paste a Discord Webhook URL if you want real-time notifications in an HR channel when LOAs are submitted.
105
+
106
+ ### 2. Permissions
107
+ Assign the following permissions to the appropriate states or groups in Alliance Auth:
108
+
109
+ | Permission | Description |
110
+ |---|---|
111
+ | `aa_loa.basic_access` | Grants access to the LOA module for normal members. Allows them to submit and manage their own LOAs. |
112
+ | `aa_loa.manage_loa` | Grants access to the HR Dashboard. Allows users to view all LOAs and submit proxy LOAs for other members. |
113
+
114
+ ### 3. Setup the Celery Periodic Task
115
+ To ensure LOA groups are automatically assigned and removed every night, you need to configure the daily Celery task in your `local.py` settings file.
116
+
117
+ Open your `myauth/settings/local.py` and add the following to your `CELERYBEAT_SCHEDULE` dictionary:
118
+
119
+ ```python
120
+ from celery.schedules import crontab
121
+
122
+ CELERYBEAT_SCHEDULE['aa_loa_sync_groups'] = {
123
+ 'task': 'aa_loa.tasks.sync_loa_groups',
124
+ 'schedule': crontab(minute='0', hour='0'),
125
+ }
126
+ ```
127
+
128
+ Restart your celery worker (`sudo systemctl restart supervisor`) and the system will automatically activate/deactivate LOAs every day at midnight!
aa_loa-0.0.1/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # Alliance Auth - Leave of Absence (aa-loa)
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/aa-loa)](https://pypi.org/project/aa-loa/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/aa-loa)](https://pypi.org/project/aa-loa/)
5
+ [![Tests](https://github.com/mbroekman/aa-loa/actions/workflows/automated-checks.yml/badge.svg)](https://github.com/mbroekman/aa-loa/actions/workflows/automated-checks.yml)
6
+
7
+ A complete Leave of Absence (LOA) management module for Alliance Auth. This module allows members to declare periods of absence so that leadership is aware of their inactivity, while seamlessly integrating with other Alliance Auth features (like Discord and Activity Trackers) to prevent accidental purges.
8
+
9
+ ## Features
10
+
11
+ - **Player Dashboard:** Easy-to-use form for players to submit a start date, end date, and an optional reason. Players can also view their past LOAs, and cancel/revoke an active LOA if they return early.
12
+ - **HR Dashboard:** A dedicated view for Directors/HR to see a live table of all active and upcoming LOAs in the alliance.
13
+ - **Proxy Submission:** Directors can submit an LOA on behalf of a member who might be unable to access a PC (e.g. emergencies).
14
+ - **Discord Role Sync:** Assigns members to a specific Django Group (e.g., `[On Leave]`) when their LOA becomes active. Alliance Auth will automatically sync this to Discord, TS, or Mumble.
15
+ - **Purge Protection:** Because the member is in the `[On Leave]` group, audit modules (like `aa-inactives` or `opcalendar`) can simply whitelist this group to exempt the member from activity purges.
16
+ - **Webhooks:** Automatically posts a Discord Embed to a webhook URL whenever an LOA is submitted.
17
+ - **Welcome Back Notification:** A Celery task automatically removes the member from the LOA group when the end date passes, and sends a "Welcome Back" Alliance Auth notification.
18
+
19
+ ### 🤖 Optional: Discord Bot Integration (`aa-discordbot`)
20
+
21
+ While `aa-loa` works perfectly fine on its own, it integrates natively with [aa-discordbot](https://github.com/pvyParts/allianceauth-discordbot).
22
+ If your alliance has `aa-discordbot` installed, the internal Alliance Auth notifications generated by this module will automatically be forwarded as **Direct Messages (DMs)** on Discord to the user. This applies to:
23
+ - 📩 The "Welcome Back" notification when an LOA automatically expires.
24
+ - 🛑 The "Cancellation" notification when HR manually cancels a player's LOA.
25
+
26
+ ---
27
+
28
+ ## Installation
29
+
30
+ ### 1. Install the Python Package
31
+ Activate your Alliance Auth virtual environment and install the package:
32
+
33
+ ```bash
34
+ pip install -e /path/to/aa-loa/
35
+ ```
36
+
37
+ ### 2. Update Alliance Auth Settings
38
+ Open your `myauth/settings/local.py` file and add `'aa_loa'` to your `INSTALLED_APPS`:
39
+
40
+ ```python
41
+ INSTALLED_APPS += [
42
+ # ... other apps
43
+ 'aa_loa',
44
+ ]
45
+ ```
46
+
47
+ ### 3. Run Database Migrations
48
+ Run the migrations to create the database tables:
49
+
50
+ ```bash
51
+ python manage.py makemigrations aa_loa
52
+ python manage.py migrate
53
+ ```
54
+
55
+ ### 4. Restart Services
56
+ Restart your Alliance Auth web service and Celery workers so they pick up the new code and background tasks:
57
+
58
+ ```bash
59
+ sudo systemctl restart supervisor
60
+ ```
61
+ *(Or restart the specific services depending on your hosting environment).*
62
+
63
+ ---
64
+
65
+ ## Configuration
66
+
67
+ ### 1. Setup the LOA Group
68
+ 1. Go to the **Django Admin Panel** (`/admin/`).
69
+ 2. Navigate to **Authentication and Authorization** -> **Groups** and create a new group (e.g., `On Leave`).
70
+ - *Optional:* Configure this group in your Discord/TS services to map to a specific `[On Leave]` Discord Role.
71
+ 3. Navigate to **Leave of Absence Configuration** -> **LOA Config**.
72
+ 4. Add a new configuration row.
73
+ 5. Select the `On Leave` group you just created.
74
+ 6. *(Optional)* Paste a Discord Webhook URL if you want real-time notifications in an HR channel when LOAs are submitted.
75
+
76
+ ### 2. Permissions
77
+ Assign the following permissions to the appropriate states or groups in Alliance Auth:
78
+
79
+ | Permission | Description |
80
+ |---|---|
81
+ | `aa_loa.basic_access` | Grants access to the LOA module for normal members. Allows them to submit and manage their own LOAs. |
82
+ | `aa_loa.manage_loa` | Grants access to the HR Dashboard. Allows users to view all LOAs and submit proxy LOAs for other members. |
83
+
84
+ ### 3. Setup the Celery Periodic Task
85
+ To ensure LOA groups are automatically assigned and removed every night, you need to configure the daily Celery task in your `local.py` settings file.
86
+
87
+ Open your `myauth/settings/local.py` and add the following to your `CELERYBEAT_SCHEDULE` dictionary:
88
+
89
+ ```python
90
+ from celery.schedules import crontab
91
+
92
+ CELERYBEAT_SCHEDULE['aa_loa_sync_groups'] = {
93
+ 'task': 'aa_loa.tasks.sync_loa_groups',
94
+ 'schedule': crontab(minute='0', hour='0'),
95
+ }
96
+ ```
97
+
98
+ Restart your celery worker (`sudo systemctl restart supervisor`) and the system will automatically activate/deactivate LOAs every day at midnight!
@@ -0,0 +1,2 @@
1
+ __version__ = "0.0.1"
2
+ default_app_config = "aa_loa.apps.LoaConfig"
@@ -0,0 +1,15 @@
1
+ from django.contrib import admin
2
+
3
+ from .models import LeaveOfAbsence, LOAConfig
4
+
5
+
6
+ @admin.register(LOAConfig)
7
+ class LOAConfigAdmin(admin.ModelAdmin):
8
+ list_display = ["__str__", "loa_group"]
9
+
10
+
11
+ @admin.register(LeaveOfAbsence)
12
+ class LeaveOfAbsenceAdmin(admin.ModelAdmin):
13
+ list_display = ["user", "start_date", "end_date", "is_active", "is_revoked"]
14
+ list_filter = ["is_revoked", "start_date", "end_date"]
15
+ search_fields = ["user__username"]
@@ -0,0 +1,10 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class LoaConfig(AppConfig):
5
+ name = "aa_loa"
6
+ label = "aa_loa"
7
+ verbose_name = "Leave of Absence"
8
+
9
+ def ready(self):
10
+ import aa_loa.signals # noqa
@@ -0,0 +1,30 @@
1
+ from allianceauth import hooks
2
+ from allianceauth.services.hooks import MenuItemHook, UrlHook
3
+
4
+ from . import urls
5
+
6
+
7
+ class LoaMenuItem(MenuItemHook):
8
+ def __init__(self):
9
+ MenuItemHook.__init__(
10
+ self,
11
+ "Leave of Absence",
12
+ "fas fa-plane-departure fa-fw",
13
+ "aa_loa:index",
14
+ navactive=["aa_loa:"],
15
+ )
16
+
17
+ def render(self, request):
18
+ if request.user.has_perm("aa_loa.basic_access"):
19
+ return MenuItemHook.render(self, request)
20
+ return ""
21
+
22
+
23
+ @hooks.register("menu_item_hook")
24
+ def register_menu():
25
+ return LoaMenuItem()
26
+
27
+
28
+ @hooks.register("url_hook")
29
+ def register_urls():
30
+ return UrlHook(urls, "aa_loa", r"^loa/")
@@ -0,0 +1,50 @@
1
+ from django import forms
2
+ from django.contrib.auth.models import User
3
+ from django.utils import timezone
4
+
5
+ from .models import LeaveOfAbsence
6
+
7
+
8
+ class PlayerLOAForm(forms.ModelForm):
9
+ class Meta:
10
+ model = LeaveOfAbsence
11
+ fields = ["start_date", "end_date", "reason"]
12
+ widgets = {
13
+ "start_date": forms.DateInput(attrs={"type": "date", "class": "form-control", "onkeydown": "return false"}),
14
+ "end_date": forms.DateInput(attrs={"type": "date", "class": "form-control", "onkeydown": "return false"}),
15
+ "reason": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
16
+ }
17
+
18
+ def __init__(self, *args, **kwargs):
19
+ super().__init__(*args, **kwargs)
20
+ # Set minimum date for HTML5 date pickers to today
21
+ today_str = timezone.localdate().strftime("%Y-%m-%d")
22
+ self.fields['start_date'].widget.attrs.update({'min': today_str})
23
+ self.fields['end_date'].widget.attrs.update({'min': today_str})
24
+
25
+ def clean_start_date(self):
26
+ start_date = self.cleaned_data.get("start_date")
27
+ if start_date and start_date < timezone.localdate():
28
+ raise forms.ValidationError("Start date cannot be in the past.")
29
+ return start_date
30
+
31
+ def clean(self):
32
+ cleaned_data = super().clean()
33
+ start_date = cleaned_data.get("start_date")
34
+ end_date = cleaned_data.get("end_date")
35
+
36
+ if start_date and end_date:
37
+ if start_date > end_date:
38
+ raise forms.ValidationError("End date cannot be before start date.")
39
+ return cleaned_data
40
+
41
+
42
+ class DirectorLOAForm(PlayerLOAForm):
43
+ user = forms.ModelChoiceField(
44
+ queryset=User.objects.all().order_by("username"),
45
+ widget=forms.Select(attrs={"class": "form-control"}),
46
+ label="Player",
47
+ )
48
+
49
+ class Meta(PlayerLOAForm.Meta):
50
+ fields = ["user", "start_date", "end_date", "reason"]
@@ -0,0 +1,49 @@
1
+ # Generated by Django 5.2.15 on 2026-07-24 14:45
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = [
13
+ ('auth', '0012_alter_user_first_name_max_length'),
14
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name='LeaveOfAbsence',
20
+ fields=[
21
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22
+ ('start_date', models.DateField()),
23
+ ('end_date', models.DateField()),
24
+ ('reason', models.TextField(blank=True, null=True)),
25
+ ('created_at', models.DateTimeField(auto_now_add=True)),
26
+ ('updated_at', models.DateTimeField(auto_now=True)),
27
+ ('is_revoked', models.BooleanField(default=False, help_text='Set to true if the user returned early.')),
28
+ ('notified_return', models.BooleanField(default=False, help_text='Set to true once the welcome back notification is sent.')),
29
+ ('submitted_by', models.ForeignKey(blank=True, help_text='If submitted by a Director, this logs who did it.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='proxy_loas', to=settings.AUTH_USER_MODEL)),
30
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='loas', to=settings.AUTH_USER_MODEL)),
31
+ ],
32
+ options={
33
+ 'ordering': ['-start_date'],
34
+ 'permissions': (('basic_access', 'Can access the LOA module'), ('manage_loa', 'Can manage LOAs for other users and view HR dashboard')),
35
+ },
36
+ ),
37
+ migrations.CreateModel(
38
+ name='LOAConfig',
39
+ fields=[
40
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
41
+ ('discord_webhook_url', models.URLField(blank=True, help_text='Optional Discord Webhook URL for Director notifications when LOAs are created.', null=True)),
42
+ ('loa_group', models.ForeignKey(blank=True, help_text='The Django Group to assign to users while their LOA is active.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='auth.group')),
43
+ ],
44
+ options={
45
+ 'verbose_name': 'LOA Config',
46
+ 'verbose_name_plural': 'LOA Config',
47
+ },
48
+ ),
49
+ ]
File without changes
@@ -0,0 +1,81 @@
1
+ from django.contrib.auth.models import Group, User
2
+ from django.db import models
3
+ from django.utils import timezone
4
+
5
+
6
+ class LOAConfig(models.Model):
7
+ loa_group = models.ForeignKey(
8
+ Group,
9
+ on_delete=models.SET_NULL,
10
+ null=True,
11
+ blank=True,
12
+ help_text="The Django Group to assign to users while their LOA is active.",
13
+ )
14
+ discord_webhook_url = models.URLField(
15
+ blank=True,
16
+ null=True,
17
+ help_text="Optional Discord Webhook URL for Director notifications when LOAs are created.",
18
+ )
19
+
20
+ class Meta:
21
+ verbose_name = "LOA Config"
22
+ verbose_name_plural = "LOA Config"
23
+
24
+ def __str__(self):
25
+ return "Leave of Absence Configuration"
26
+
27
+
28
+ class LeaveOfAbsence(models.Model):
29
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="loas")
30
+ start_date = models.DateField()
31
+ end_date = models.DateField()
32
+ reason = models.TextField(blank=True, null=True)
33
+
34
+ submitted_by = models.ForeignKey(
35
+ User,
36
+ on_delete=models.SET_NULL,
37
+ null=True,
38
+ blank=True,
39
+ related_name="proxy_loas",
40
+ help_text="If submitted by a Director, this logs who did it.",
41
+ )
42
+ created_at = models.DateTimeField(auto_now_add=True)
43
+ updated_at = models.DateTimeField(auto_now=True)
44
+
45
+ is_revoked = models.BooleanField(
46
+ default=False, help_text="Set to true if the user returned early."
47
+ )
48
+ notified_return = models.BooleanField(
49
+ default=False, help_text="Set to true once the welcome back notification is sent."
50
+ )
51
+
52
+ class Meta:
53
+ permissions = (
54
+ ("basic_access", "Can access the LOA module"),
55
+ ("manage_loa", "Can manage LOAs for other users and view HR dashboard"),
56
+ )
57
+ ordering = ["-start_date"]
58
+
59
+ def __str__(self):
60
+ return f"LOA: {self.user.username} ({self.start_date} to {self.end_date})"
61
+
62
+ @property
63
+ def is_active(self):
64
+ if self.is_revoked:
65
+ return False
66
+ today = timezone.now().date()
67
+ return self.start_date <= today <= self.end_date
68
+
69
+ @property
70
+ def is_past(self):
71
+ if self.is_revoked:
72
+ return True
73
+ today = timezone.now().date()
74
+ return self.end_date < today
75
+
76
+ @property
77
+ def is_future(self):
78
+ if self.is_revoked:
79
+ return False
80
+ today = timezone.now().date()
81
+ return self.start_date > today
@@ -0,0 +1,14 @@
1
+ from django.db.models.signals import post_save
2
+ from django.dispatch import receiver
3
+
4
+ from .models import LeaveOfAbsence
5
+ from .tasks import send_loa_webhook, sync_loa_groups
6
+
7
+
8
+ @receiver(post_save, sender=LeaveOfAbsence)
9
+ def on_loa_saved(sender, instance, created, **kwargs):
10
+ if created:
11
+ send_loa_webhook.delay(instance.id)
12
+
13
+ # Trigger a sync immediately so if it's active today, they get the role
14
+ sync_loa_groups.delay()
@@ -0,0 +1,106 @@
1
+ import logging
2
+
3
+ import requests
4
+ from celery import shared_task
5
+ from django.utils import timezone
6
+ from django.db import models
7
+ from allianceauth.services.hooks import get_extension_logger
8
+ from allianceauth.notifications import notify
9
+
10
+ from .models import LeaveOfAbsence, LOAConfig
11
+
12
+ logger = get_extension_logger(__name__)
13
+
14
+
15
+ @shared_task
16
+ def sync_loa_groups():
17
+ """
18
+ Checks all LOAs and adds/removes users from the designated LOA Django group.
19
+ """
20
+ config = LOAConfig.objects.first()
21
+ if not config or not config.loa_group:
22
+ logger.warning("LOA Config or LOA Group is not set. Cannot sync groups.")
23
+ return
24
+
25
+ loa_group = config.loa_group
26
+ today = timezone.now().date()
27
+
28
+ # 1. Add users with active LOAs
29
+ active_loas = LeaveOfAbsence.objects.filter(
30
+ start_date__lte=today,
31
+ end_date__gte=today,
32
+ is_revoked=False
33
+ )
34
+ for loa in active_loas:
35
+ if not loa.user.groups.filter(id=loa_group.id).exists():
36
+ loa.user.groups.add(loa_group)
37
+ logger.info(f"Added {loa.user.username} to LOA Group.")
38
+
39
+ # 2. Remove users whose LOAs have ended or been revoked,
40
+ # but ONLY if they don't have another overlapping active LOA.
41
+ inactive_loas = LeaveOfAbsence.objects.filter(
42
+ models.Q(end_date__lt=today) | models.Q(is_revoked=True)
43
+ )
44
+
45
+ for loa in inactive_loas:
46
+ user = loa.user
47
+ # Check if they have another active LOA right now
48
+ has_active = LeaveOfAbsence.objects.filter(
49
+ user=user,
50
+ start_date__lte=today,
51
+ end_date__gte=today,
52
+ is_revoked=False
53
+ ).exists()
54
+
55
+ if not has_active and user.groups.filter(id=loa_group.id).exists():
56
+ user.groups.remove(loa_group)
57
+ logger.info(f"Removed {user.username} from LOA Group.")
58
+
59
+ # Send Welcome Back Notification if not sent
60
+ if not loa.notified_return and loa.end_date < today and not loa.is_revoked:
61
+ notify(
62
+ user=user,
63
+ title="Welcome Back from LOA",
64
+ message="Welcome back! Your LOA has expired. Let us know if you need an extension.",
65
+ level="info"
66
+ )
67
+ loa.notified_return = True
68
+ loa.save(update_fields=["notified_return"])
69
+
70
+
71
+ @shared_task
72
+ def send_loa_webhook(loa_id):
73
+ """
74
+ Sends a Discord webhook notification when a new LOA is submitted.
75
+ """
76
+ config = LOAConfig.objects.first()
77
+ if not config or not config.discord_webhook_url:
78
+ return
79
+
80
+ try:
81
+ loa = LeaveOfAbsence.objects.get(id=loa_id)
82
+ except LeaveOfAbsence.DoesNotExist:
83
+ return
84
+
85
+ title = "New Leave of Absence"
86
+ if loa.submitted_by:
87
+ title = f"Proxy LOA submitted by {loa.submitted_by.username}"
88
+
89
+ embed = {
90
+ "title": title,
91
+ "color": 16753920, # Orange
92
+ "fields": [
93
+ {"name": "Player", "value": loa.user.username, "inline": True},
94
+ {"name": "Start", "value": str(loa.start_date), "inline": True},
95
+ {"name": "End", "value": str(loa.end_date), "inline": True},
96
+ ]
97
+ }
98
+
99
+ if loa.reason:
100
+ embed["fields"].append({"name": "Reason", "value": loa.reason[:1000], "inline": False})
101
+
102
+ payload = {"embeds": [embed]}
103
+ try:
104
+ requests.post(config.discord_webhook_url, json=payload, timeout=5)
105
+ except Exception as e:
106
+ logger.error(f"Failed to send LOA webhook: {e}")
@@ -0,0 +1,147 @@
1
+ {% extends "allianceauth/base-bs5.html" %}
2
+ {% load i18n %}
3
+ {% load django_bootstrap5 %}
4
+
5
+ {% block page_title %}{% translate "LOA HR Dashboard" %}{% endblock page_title %}
6
+
7
+ {% block extra_css %}
8
+ {% include 'bundles/datatables-css-bs5.html' %}
9
+ {% endblock %}
10
+
11
+ {% block content %}
12
+ <div class="col-lg-12">
13
+ <h1 class="page-header text-center">Leave of Absence - HR Dashboard</h1>
14
+
15
+ <div class="row" style="margin-bottom: 20px;">
16
+ <div class="col-md-12 text-right">
17
+ <a href="{% url 'aa_loa:index' %}" class="btn btn-primary">Go to Player Dashboard</a>
18
+ </div>
19
+ </div>
20
+
21
+ <div class="row">
22
+ <!-- Proxy Submit -->
23
+ <div class="col-md-4">
24
+ <div class="panel panel-warning">
25
+ <div class="panel-heading">
26
+ <h3 class="panel-title">Proxy Submit LOA</h3>
27
+ </div>
28
+ <div class="panel-body">
29
+ <form method="post" action="{% url 'aa_loa:hr_dashboard' %}">
30
+ {% csrf_token %}
31
+ {% bootstrap_form form %}
32
+ <br>
33
+ <button type="submit" class="btn btn-warning btn-block">Submit Proxy LOA</button>
34
+ </form>
35
+ </div>
36
+ </div>
37
+ </div>
38
+
39
+ <!-- All LOAs -->
40
+ <div class="col-md-8">
41
+ <div class="panel panel-default">
42
+ <div class="panel-heading">
43
+ <h3 class="panel-title">All LOAs</h3>
44
+ </div>
45
+ <div class="panel-body">
46
+ <table class="table table-striped table-hover" id="loaTable">
47
+ <thead>
48
+ <tr>
49
+ <th>Player</th>
50
+ <th>Status</th>
51
+ <th>Start Date</th>
52
+ <th>End Date</th>
53
+ <th>Reason</th>
54
+ <th>Proxy</th>
55
+ <th>Actions</th>
56
+ </tr>
57
+ </thead>
58
+ <tbody>
59
+ {% for loa in loas %}
60
+ <tr>
61
+ <td>{{ loa.user.username }}</td>
62
+ <td>
63
+ {% if loa.is_revoked %}
64
+ <span class="label label-default">Cancelled</span>
65
+ {% elif loa.is_active %}
66
+ <span class="label label-success">Active</span>
67
+ {% elif loa.is_future %}
68
+ <span class="label label-info">Upcoming</span>
69
+ {% else %}
70
+ <span class="label label-default">Past</span>
71
+ {% endif %}
72
+ </td>
73
+ <td>{{ loa.start_date }}</td>
74
+ <td>{{ loa.end_date }}</td>
75
+ <td>{{ loa.reason|default:"-" }}</td>
76
+ <td>{{ loa.submitted_by.username|default:"-" }}</td>
77
+ <td>
78
+ {% if not loa.is_revoked and not loa.is_past %}
79
+ <!-- Trigger Modal -->
80
+ <button type="button" class="btn btn-xs btn-danger" data-bs-toggle="modal" data-bs-target="#hrRevokeModal{{ loa.id }}">
81
+ Cancel
82
+ </button>
83
+
84
+ <!-- Modal -->
85
+ <div class="modal fade" id="hrRevokeModal{{ loa.id }}" tabindex="-1" aria-labelledby="hrRevokeModalLabel{{ loa.id }}" aria-hidden="true">
86
+ <div class="modal-dialog">
87
+ <div class="modal-content">
88
+ <div class="modal-header">
89
+ <h5 class="modal-title" id="hrRevokeModalLabel{{ loa.id }}">Confirm Cancellation</h5>
90
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
91
+ </div>
92
+ <div class="modal-body">
93
+ Are you sure you want to cancel the Leave of Absence for <strong>{{ loa.user.username }}</strong>?
94
+ </div>
95
+ <div class="modal-footer">
96
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Go Back</button>
97
+ <form method="post" action="{% url 'aa_loa:hr_revoke' loa.id %}" style="display:inline;">
98
+ {% csrf_token %}
99
+ <button type="submit" class="btn btn-danger">Yes, Cancel LOA</button>
100
+ </form>
101
+ </div>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ {% else %}
106
+ -
107
+ {% endif %}
108
+ </td>
109
+ </tr>
110
+ {% endfor %}
111
+ </tbody>
112
+ </table>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ </div>
118
+ {% endblock content %}
119
+
120
+ {% block extra_javascript %}
121
+ {% include 'bundles/datatables-js-bs5.html' %}
122
+ <script>
123
+ $(document).ready(function() {
124
+ $('#loaTable').DataTable({
125
+ "order": [[ 2, "desc" ]],
126
+ "dom": "<'row'<'col-sm-12 col-md-6'l><'col-sm-12 col-md-6'f>>" +
127
+ "<'row'<'col-sm-12 mb-2'p>>" +
128
+ "<'row'<'col-sm-12'tr>>" +
129
+ "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>"
130
+ });
131
+
132
+ // When start_date changes, update the min attribute of end_date
133
+ $('#id_start_date').on('change', function() {
134
+ var startDate = $(this).val();
135
+ if (startDate) {
136
+ $('#id_end_date').attr('min', startDate);
137
+
138
+ // If end_date is now before start_date, clear it
139
+ var endDate = $('#id_end_date').val();
140
+ if (endDate && endDate < startDate) {
141
+ $('#id_end_date').val('');
142
+ }
143
+ }
144
+ });
145
+ });
146
+ </script>
147
+ {% endblock %}
@@ -0,0 +1,134 @@
1
+ {% extends "allianceauth/base-bs5.html" %}
2
+ {% load i18n %}
3
+ {% load django_bootstrap5 %}
4
+
5
+ {% block page_title %}{% translate "Leave of Absence" %}{% endblock page_title %}
6
+
7
+ {% block content %}
8
+ <div class="col-lg-12">
9
+ <h1 class="page-header text-center">Leave of Absence</h1>
10
+
11
+ {% if perms.aa_loa.manage_loa %}
12
+ <div class="row" style="margin-bottom: 20px;">
13
+ <div class="col-md-12 text-right">
14
+ <a href="{% url 'aa_loa:hr_dashboard' %}" class="btn btn-warning">Go to HR Dashboard</a>
15
+ </div>
16
+ </div>
17
+ {% endif %}
18
+
19
+ <div class="row">
20
+ <!-- New LOA Form -->
21
+ <div class="col-md-4">
22
+ <div class="panel panel-primary">
23
+ <div class="panel-heading">
24
+ <h3 class="panel-title">Submit LOA</h3>
25
+ </div>
26
+ <div class="panel-body">
27
+ <form method="post" action="{% url 'aa_loa:index' %}">
28
+ {% csrf_token %}
29
+ {% bootstrap_form form %}
30
+ <br>
31
+ <button type="submit" class="btn btn-success btn-block">Submit</button>
32
+ </form>
33
+ </div>
34
+ </div>
35
+ </div>
36
+
37
+ <!-- Existing LOAs -->
38
+ <div class="col-md-8">
39
+ <div class="panel panel-default">
40
+ <div class="panel-heading">
41
+ <h3 class="panel-title">Your LOAs</h3>
42
+ </div>
43
+ <div class="panel-body">
44
+ {% if loas %}
45
+ <table class="table table-striped table-hover">
46
+ <thead>
47
+ <tr>
48
+ <th>Status</th>
49
+ <th>Start Date</th>
50
+ <th>End Date</th>
51
+ <th>Reason</th>
52
+ <th>Actions</th>
53
+ </tr>
54
+ </thead>
55
+ <tbody>
56
+ {% for loa in loas %}
57
+ <tr>
58
+ <td>
59
+ {% if loa.is_revoked %}
60
+ <span class="label label-default">Cancelled</span>
61
+ {% elif loa.is_active %}
62
+ <span class="label label-success">Active</span>
63
+ {% elif loa.is_future %}
64
+ <span class="label label-info">Upcoming</span>
65
+ {% else %}
66
+ <span class="label label-default">Past</span>
67
+ {% endif %}
68
+ </td>
69
+ <td>{{ loa.start_date }}</td>
70
+ <td>{{ loa.end_date }}</td>
71
+ <td>{{ loa.reason|default:"-"|truncatechars:50 }}</td>
72
+ <td>
73
+ {% if loa.is_active or loa.is_future %}
74
+ <!-- Trigger Modal -->
75
+ <button type="button" class="btn btn-xs btn-danger" data-bs-toggle="modal" data-bs-target="#revokeModal{{ loa.id }}">
76
+ Return Early / Cancel
77
+ </button>
78
+
79
+ <!-- Modal -->
80
+ <div class="modal fade" id="revokeModal{{ loa.id }}" tabindex="-1" aria-labelledby="revokeModalLabel{{ loa.id }}" aria-hidden="true">
81
+ <div class="modal-dialog">
82
+ <div class="modal-content">
83
+ <div class="modal-header">
84
+ <h5 class="modal-title" id="revokeModalLabel{{ loa.id }}">Confirm Cancellation</h5>
85
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
86
+ </div>
87
+ <div class="modal-body">
88
+ Are you sure you want to return early and cancel this Leave of Absence?
89
+ </div>
90
+ <div class="modal-footer">
91
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Go Back</button>
92
+ <form method="post" action="{% url 'aa_loa:revoke' loa.id %}" style="display:inline;">
93
+ {% csrf_token %}
94
+ <button type="submit" class="btn btn-danger">Yes, Cancel LOA</button>
95
+ </form>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ {% endif %}
101
+ </td>
102
+ </tr>
103
+ {% endfor %}
104
+ </tbody>
105
+ </table>
106
+ {% else %}
107
+ <p class="text-muted">You have no active or historical LOAs.</p>
108
+ {% endif %}
109
+ </div>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ </div>
114
+ {% endblock content %}
115
+
116
+ {% block extra_javascript %}
117
+ <script>
118
+ $(document).ready(function() {
119
+ // When start_date changes, update the min attribute of end_date
120
+ $('#id_start_date').on('change', function() {
121
+ var startDate = $(this).val();
122
+ if (startDate) {
123
+ $('#id_end_date').attr('min', startDate);
124
+
125
+ // If end_date is now before start_date, clear it
126
+ var endDate = $('#id_end_date').val();
127
+ if (endDate && endDate < startDate) {
128
+ $('#id_end_date').val('');
129
+ }
130
+ }
131
+ });
132
+ });
133
+ </script>
134
+ {% endblock extra_javascript %}
@@ -0,0 +1,12 @@
1
+ from django.urls import path
2
+
3
+ from . import views
4
+
5
+ app_name = "aa_loa"
6
+
7
+ urlpatterns = [
8
+ path("", views.loa_index, name="index"),
9
+ path("revoke/<int:loa_id>/", views.revoke_loa, name="revoke"),
10
+ path("hr/", views.loa_hr_dashboard, name="hr_dashboard"),
11
+ path("hr/revoke/<int:loa_id>/", views.hr_revoke_loa, name="hr_revoke"),
12
+ ]
@@ -0,0 +1,81 @@
1
+ from django.contrib import messages
2
+ from django.contrib.auth.decorators import login_required, permission_required
3
+ from django.shortcuts import get_object_or_404, redirect, render
4
+ from django.utils import timezone
5
+
6
+ from .forms import DirectorLOAForm, PlayerLOAForm
7
+ from .models import LeaveOfAbsence
8
+ from allianceauth.notifications import notify
9
+
10
+
11
+ @login_required
12
+ @permission_required("aa_loa.basic_access")
13
+ def loa_index(request):
14
+ user_loas = LeaveOfAbsence.objects.filter(user=request.user).order_by("-start_date")
15
+
16
+ if request.method == "POST":
17
+ form = PlayerLOAForm(request.POST)
18
+ if form.is_valid():
19
+ loa = form.save(commit=False)
20
+ loa.user = request.user
21
+ loa.save()
22
+ messages.success(request, "Leave of Absence submitted successfully.")
23
+ return redirect("aa_loa:index")
24
+ else:
25
+ form = PlayerLOAForm()
26
+
27
+ return render(
28
+ request,
29
+ "aa_loa/index.html",
30
+ {"loas": user_loas, "form": form},
31
+ )
32
+
33
+
34
+ @login_required
35
+ @permission_required("aa_loa.basic_access")
36
+ def revoke_loa(request, loa_id):
37
+ loa = get_object_or_404(LeaveOfAbsence, id=loa_id, user=request.user)
38
+ if request.method == "POST":
39
+ loa.is_revoked = True
40
+ loa.save()
41
+ messages.success(request, "LOA cancelled/revoked.")
42
+ return redirect("aa_loa:index")
43
+
44
+
45
+ @login_required
46
+ @permission_required("aa_loa.manage_loa")
47
+ def hr_revoke_loa(request, loa_id):
48
+ loa = get_object_or_404(LeaveOfAbsence, id=loa_id)
49
+ if request.method == "POST":
50
+ loa.is_revoked = True
51
+ loa.save()
52
+ messages.success(request, f"LOA for {loa.user.username} cancelled by HR.")
53
+ notify(
54
+ user=loa.user,
55
+ title="Leave of Absence Cancelled",
56
+ message=f"Your Leave of Absence starting on {loa.start_date} was cancelled by HR ({request.user.username}).",
57
+ level="warning"
58
+ )
59
+ return redirect("aa_loa:hr_dashboard")
60
+
61
+ @login_required
62
+ @permission_required("aa_loa.manage_loa")
63
+ def loa_hr_dashboard(request):
64
+ all_loas = LeaveOfAbsence.objects.all().select_related("user").order_by("-start_date")
65
+
66
+ if request.method == "POST":
67
+ form = DirectorLOAForm(request.POST)
68
+ if form.is_valid():
69
+ loa = form.save(commit=False)
70
+ loa.submitted_by = request.user
71
+ loa.save()
72
+ messages.success(request, f"Proxy LOA submitted for {loa.user.username}.")
73
+ return redirect("aa_loa:hr_dashboard")
74
+ else:
75
+ form = DirectorLOAForm()
76
+
77
+ return render(
78
+ request,
79
+ "aa_loa/hr_dashboard.html",
80
+ {"loas": all_loas, "form": form},
81
+ )
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ build-backend = "hatchling.build"
3
+ requires = [
4
+ "hatchling",
5
+ ]
6
+
7
+ [project]
8
+ name = "aa-loa"
9
+ description = "Leave of Absence module for Alliance Auth"
10
+ readme = "README.md"
11
+ keywords = [
12
+ "allianceauth",
13
+ "leave_of_absence",
14
+ "loa",
15
+ ]
16
+ license = { text = "MIT" }
17
+ authors = [
18
+ { name = "Maddog Broekman", email = "maddogbroekman@gmail.com" },
19
+ ]
20
+ requires-python = ">=3.10,<3.14"
21
+ classifiers = [
22
+ "Environment :: Web Environment",
23
+ "Framework :: Django",
24
+ "Framework :: Django :: 4.2",
25
+ "Intended Audience :: Developers",
26
+ "License :: OSI Approved :: MIT License",
27
+ "Operating System :: OS Independent",
28
+ "Programming Language :: Python",
29
+ "Programming Language :: Python :: 3 :: Only",
30
+ "Programming Language :: Python :: 3.10",
31
+ "Programming Language :: Python :: 3.11",
32
+ "Programming Language :: Python :: 3.12",
33
+ "Topic :: Internet :: WWW/HTTP",
34
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
35
+ ]
36
+ dynamic = [
37
+ "version",
38
+ ]
39
+ dependencies = [
40
+ "allianceauth>=3.6.0",
41
+ "discord-webhook>=1.3.0",
42
+ ]
43
+ urls.Changelog = "https://github.com/mbroekman/aa-loa/blob/main/CHANGELOG.md"
44
+ urls.Homepage = "https://github.com/mbroekman/aa-loa"
45
+ urls.Source = "https://github.com/mbroekman/aa-loa"
46
+ urls.Tracker = "https://github.com/mbroekman/aa-loa/issues"
47
+
48
+ [tool.hatch]
49
+ version.path = "aa_loa/__init__.py"
50
+ build.include = [
51
+ "/aa_loa",
52
+ ]