diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b2b63fc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,84 @@ +name: Release + +on: + push: + tags: + - "*" + +permissions: + contents: write + id-token: write + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "latest" + enable-cache: true + + - name: Install dependencies + run: uv sync --dev + + - name: Run type checking + run: uv run mypy . + + - name: Run linting + run: uv run ruff check . + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "latest" + enable-cache: true + + - name: Install dependencies + run: uv sync --dev --python ${{ matrix.python-version }} + + - name: Run tests + run: uv run pytest + + build-and-publish: + needs: [lint, test] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "latest" + enable-cache: true + + - name: Build package + run: uv build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Create changelog text + id: changelog + uses: loopwerk/tag-changelog@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + exclude_types: other,doc,chore,build + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + body: ${{ steps.changelog.outputs.changes }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..debbb8a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,20 @@ +name: Unit tests + +on: + push: + branches: ["*"] + pull_request: + branches: ["*"] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + + steps: + - uses: actions/checkout@v3 + - uses: astral-sh/setup-uv@v3 + - run: uv python install + - run: uv sync --group dev + - run: uv run pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee5b516 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info +.venv +.claude +.pytest_cache +example/db.sqlite3 +/uv.lock \ No newline at end of file diff --git a/AUTHORS b/AUTHORS deleted file mode 100644 index 2ea9c98..0000000 --- a/AUTHORS +++ /dev/null @@ -1,8 +0,0 @@ -The PRIMARY AUTHORS are: - - * Kevin Renskers - -ADDITIONAL CONTRIBUTORS include: - - * Jeffrey Gelens - * Harro van der Klauw diff --git a/CHANGELOG b/CHANGELOG deleted file mode 100644 index ede7a8f..0000000 --- a/CHANGELOG +++ /dev/null @@ -1,32 +0,0 @@ -0.1.0: November 9, 2011 -- Initial version - -0.1.1: November 24, 2011 -- Bugfixes - -0.1.2: December 1, 2011 -- The DjangoMessagesNotificationBackend can now use the subject as well as the text -- Easier to subclass BaseNotification and set your own default properties -- Changed documentation to reStructuredText, in preparation of move to PyPi - -0.1.3: December 5, 2011 -- Completely separated notification types and backends -- Rendered subject and text are saved in database queue -- Simpler to create notification, no more .do('add') -- New get_recipients method -- Able to set a different subject and/or text per different backend -- Respect FAIL_SILENT in _get_backends(self) -- Completely removed the Django messages backend (it didn't make sense) -- Updated docs and usage examples - -0.2: December 6, 2011 -- Define __getattr__ for easy access to kwargs -- Start of new settings app, where users can select which types they're interested in -- Users can also select backends per type - -0.2.1: December 7, 2011 -- New model DisabledNotificationsTypeBackend: instead of saving what backend a user wants to use, we save what he does not want to use instead - -0.2.2: February 5, 2012 -- New timed queue, send notifications after a certain time -- The default types and backends are no longer registered by default, the developer needs to do this explicitly diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..89b9bd1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,101 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +### Development + +```bash +# Install dependencies (using uv) +uv sync + +# Run tests +uv run pytest + +# Run a single test file +uv run pytest tests/test_models.py + +# Run tests with verbose output +uv run pytest -v + +# Type checking +uv run . + +# Linting and formatting +uv run ruff check . +uv run ruff format . +``` + +## Architecture Overview + +This is a Django package for handling generic notifications across multiple channels (email, website, etc.) with configurable delivery frequencies. + +### Core Components + +1. **Registry Pattern** (`registry.py`): Central registry that manages notification types, channels, and frequencies. All components must be registered here to be available. + +2. **Notification Types** (`types.py`): Define different kinds of notifications (e.g., SystemMessage). Each type specifies: + + - Default email frequency (realtime vs digest) + - Required channels that cannot be disabled + - Dynamic subject/text generation methods + - Custom channels can be added by subclassing `NotificationType` + +3. **Channels** (`channels.py`): Delivery mechanisms for notifications: + + - `WebsiteChannel`: Stores in database for UI display + - `EmailChannel`: Sends via email (supports realtime + digest) + - Custom channels can be added by subclassing `NotificationChannel` + +4. **Frequencies** (`frequencies.py`): Email delivery timing options: + + - `RealtimeFrequency`: Send immediately + - `DailyFrequency`: Bundle into daily digest + - Custom frequencies can be added by subclassing `NotificationFrequency` + +5. **Models** (`models.py`): + - `Notification`: Core notification instance with recipient, type, channels, content + - `DisabledNotificationTypeChannel`: Opt-out preferences (presence = disabled) + - `EmailFrequency`: Per-user email frequency preferences + +### Key Design Decisions + +- **Opt-out model**: Notifications are enabled by default; users disable specific type/channel combinations +- **Channel determination at creation**: When a notification is created, enabled channels are determined and stored in the `channels` JSONField +- **Digest processing**: Email digests are handled by a management command that queries unsent, unread notifications +- **Generic relations**: Notifications can reference any Django model via ContentType/GenericForeignKey +- **PostgreSQL optimization**: Uses GIN indexes for efficient JSONField queries + +### Common Workflows + +1. **Sending a notification**: + + ```python + from generic_notifications import send_notification + from myapp.notifications import CommentNotification + + send_notification( + recipient=user, + notification_type=CommentNotification, + actor=commenter, + target=post, + subject="New comment", + text="Someone commented on your post" + ) + ``` + +2. **Registering a new notification type**: + + ```python + from generic_notifications.types import NotificationType, register + + @register + class CommentNotification(NotificationType): + key = "comment" + name = "Comments" + description = "When someone comments on your content" + default_email_frequency = DailyFrequency + ``` + +3. **User preferences**: Managed through `DisabledNotificationTypeChannel` and `EmailFrequency` models diff --git a/INSTALL.rst b/INSTALL.rst deleted file mode 100644 index b233dda..0000000 --- a/INSTALL.rst +++ /dev/null @@ -1,12 +0,0 @@ -Requirements -============ -If you wish to use the provided settings app, you will need Django 1.3 or higher, due to the Class Based Views that -are used. Otherwise Django 1.2 should be sufficient. - -Install -======= -1. Get the code: ``pip install django-generic-notifications`` -2. Add 'notifications' to your INSTALLED_APPS -3. Add ``url(r'^notifications/settings/$', include('notifications.urls')),`` to urls.py -4. Sync the database (south migrations are provided) -5. Add ``./manage.py process_notifications`` to your cron diff --git a/LICENSE b/LICENSE index 3677aba..20f1df2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,27 +1,21 @@ -Copyright (c) 2011, Kevin Renskers and individual contributors. -All rights reserved. +MIT License -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Copyright (c) Loopwerk - 1. Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: - 2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. - 3. Neither the name of django-generic-notifications nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 9279346..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,6 +0,0 @@ -include AUTHORS -include LICENSE -include CHANGELOG -include INSTALL.rst -include README.rst -include USAGE.rst diff --git a/README.md b/README.md new file mode 100644 index 0000000..00065ad --- /dev/null +++ b/README.md @@ -0,0 +1,333 @@ +# Django Generic Notifications + +A flexible, multi-channel notification system for Django applications with built-in support for email digests, user preferences, and extensible delivery channels. + +## Features + +- **Multi-channel delivery**: Send notifications through multiple channels (website, email, and custom channels) +- **Flexible email frequencies**: Support for real-time and digest emails (daily, or custom schedules) +- **Notification grouping**: Prevent repeated notifications by grouping notifications based on your own custom logic +- **User preferences**: Fine-grained control over notification types and delivery channels +- **Extensible architecture**: Easy to add custom notification types, channels, and frequencies +- **Generic relations**: Link notifications to any Django model +- **Template support**: Customizable email templates for each notification type +- **Developer friendly**: Simple API for sending notifications with automatic channel routing +- **Full type hints**: Complete type annotations for better IDE support and type checking + +## Installation + +All instruction in this document use [uv](https://github.com/astral-sh/uv), but of course pip or Poetry will also work just fine. + +```bash +uv add django-generic-notifications +``` + +Add to your `INSTALLED_APPS`: + +```python +INSTALLED_APPS = [ + ... + "generic_notifications", + ... +] +``` + +Run migrations: + +```bash +uv run ./manage.py migrate generic_notifications +``` + +## Quick Start + +### 1. Define a notification type + +```python +# myapp/notifications.py +from generic_notifications.types import NotificationType, register + +@register +class CommentNotification(NotificationType): + key = "comment" + name = "Comment Notifications" + description = "When someone comments on your posts" +``` + +### 2. Send a notification + +```python +from generic_notifications import send_notification +from myapp.notifications import CommentNotification + +# Send a notification (only `recipient` and `notification_type` are required) +notification = send_notification( + recipient=post.author, + notification_type=CommentNotification, + actor=comment.user, + target=post, + subject=f"{comment.user.get_full_name()} commented on your post", + text=f"{comment.user.get_full_name()} left a comment: {comment.text[:100]}", + url=f"/posts/{post.id}#comment-{comment.id}", +) +``` + +### 3. Set up email digest sending + +Create a cron job to send daily digests: + +```bash +# Send daily digests at 9 AM +0 9 * * * cd /path/to/project && uv run ./manage.py send_digest_emails --frequency daily +``` + +## User Preferences + +By default every user gets notifications of all registered types delivered to every registered channel, but users can opt-out of receiving notification types, per channel. + +All notification types default to daily digest, except for `SystemMessage` which defaults to real-time. Users can choose different frequency per notification type. + +```python +from generic_notifications.models import DisabledNotificationTypeChannel, EmailFrequency +from generic_notifications.channels import EmailChannel +from generic_notifications.frequencies import RealtimeFrequency +from myapp.notifications import CommentNotification + +# Disable email channel for comment notifications +DisabledNotificationTypeChannel.objects.create( + user=user, + notification_type=CommentNotification.key, + channel=EmailChannel.key +) + +# Change to realtime digest for a notification type +EmailFrequency.objects.update_or_create( + user=user, + notification_type="comment", + defaults={'frequency': RealtimeFrequency.key} +) +``` + +This project doesn’t come with a UI (view + template) for managing user preferences, but an example is provided in the [example app](#example-app). + +## Custom Channels + +Create custom delivery channels: + +```python +from generic_notifications.channels import NotificationChannel, register + +@register +class SMSChannel(NotificationChannel): + key = "sms" + name = "SMS" + + def process(self, notification): + # Send SMS using your preferred service + send_sms( + to=notification.recipient.phone_number, + message=notification.get_text() + ) +``` + +## Custom Frequencies + +Add custom email frequencies: + +```python +from generic_notifications.frequencies import NotificationFrequency, register + +@register +class WeeklyFrequency(NotificationFrequency): + key = "weekly" + name = "Weekly digest" + is_realtime = False + description = "Receive a weekly summary every Monday" +``` + +When you add custom email frequencies you’ll have to run `send_digest_emails` for them as well. For example, if you created that weekly digest: + +```bash +# Send weekly digest every Monday at 9 AM +0 9 * * 1 cd /path/to/project && uv run ./manage.py send_digest_emails --frequency weekly +``` + +## Email Templates + +Customize email templates by creating these files in your templates directory: + +### Real-time emails + +- `notifications/email/realtime/{notification_type}_subject.txt` +- `notifications/email/realtime/{notification_type}.html` +- `notifications/email/realtime/{notification_type}.txt` + +### Digest emails + +- `notifications/email/digest/subject.txt` +- `notifications/email/digest/message.html` +- `notifications/email/digest/message.txt` + +## Advanced Usage + +### Required Channels + +Make certain channels mandatory for critical notifications: + +```python +from generic_notifications.channels import EmailChannel + +@register +class SecurityAlert(NotificationType): + key = "security_alert" + name = "Security Alerts" + description = "Important security notifications" + required_channels = [EmailChannel] # Cannot be disabled +``` + +### Querying Notifications + +```python +from generic_notifications.models import Notification +from generic_notifications.lib import get_unread_count, get_notifications, mark_notifications_as_read + +# Get unread count for a user +unread_count = get_unread_count(user=user, channel=WebsiteChannel) + +# Get unread notifications for a user +unread_notifications = get_notifications(user=user, channel=WebsiteChannel, unread_only=True) + +# Get notifications by channel +email_notifications = Notification.objects.for_channel(WebsiteChannel) + +# Mark as read +notification.mark_as_read() + +# Mark all as read +mark_notifications_as_read(user=user) +``` + +### Notification Grouping + +Prevent notification spam by grouping similar notifications together. Instead of creating multiple "You received a comment" notifications, you can update an existing notification to say "You received 3 comments". + +```python +@register +class CommentNotification(NotificationType): + key = "comment" + name = "Comment Notifications" + description = "When someone comments on your posts" + + @classmethod + def should_save(cls, notification): + # Look for existing unread notification with same actor and target + existing = Notification.objects.filter( + recipient=notification.recipient, + notification_type=notification.notification_type, + actor=notification.actor, + content_type_id=notification.content_type_id, + object_id=notification.object_id, + read__isnull=True, + ).first() + + if existing: + # Update count in metadata + count = existing.metadata.get("count", 1) + existing.metadata["count"] = count + 1 + existing.save() + return False # Don't create new notification + + # First notification of this type, so it should be saved + return True + + def get_text(self, notification): + count = notification.metadata.get("count", 1) + actor_name = notification.actor.get_full_name() + + if count == 1: + return f"{actor_name} commented on your post" + else: + return f"{actor_name} left {count} comments on your post" +``` + +The `should_save` method is called before saving each notification. Return `False` to prevent creating a new notification and instead update an existing one. This gives you complete control over grouping logic - you might group by time windows, actors, targets, or any other criteria. + +## Performance Considerations + +### Accessing `notification.target` + +While you can store any object into a notification's `target` field, it's usually not a great idea to use this field to dynamically create the notification's subject and text, as the `target` generic relationship can't be prefetched more than one level deep. + +In other words, something like this will cause an N+1 query problem when you show a list of notifications in a table, for example: + +```python +class Comment(models.Model): + article = models.ForeignKey(Article, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + comment_text = models.TextField() + + +@register +class CommentNotificationType(NotificationType): + key = "comment_notification" + name = "Comments" + description = "You received a comment" + + def get_text(self, notification): + actor_name = notification.actor.full_name + article = notification.target.article + comment_text = notification.target.comment.comment_text + return f'{actor_name} commented on your article "{article.title}": "{comment_text}"' + +``` + +The problem is `target.article`, which cannot be prefetched and thus causes another query for every notification. This is why it’s better to store the subject, text and url in the notification itself, rather than relying on `target` dynamically. + +### Non-blocking email sending + +The email channel (EmailChannel) will send real-time emails using Django’s built-in `send_mail` method. This is a blocking function call, meaning that while a connection with the SMTP server is made and the email is sent off, the process that’s sending the notification has to wait. This is not ideal, but easily solved by using something like [django-mailer](https://github.com/pinax/django-mailer/), which provides a queueing backend for `send_mail`. This means that sending email no longer is a blocking action + +## Example app + +An example app is provided, which shows how to create a custom notification type, how to send a notification, it has a nice looking notification center with unread notifications as well as an archive of all read notifications, plus a settings view where you can manage notification preferences. + +```bash +cd example +uv run ./manage.py migrate +uv run ./manage.py runserver +``` + +Then open http://127.0.0.1:8000/. + +## Development + +### Setup + +```bash +# Clone the repository +git clone https://github.com/loopwerk/django-generic-notifications.git +cd django-generic-notifications +``` + +### Testing + +```bash +# Run all tests +uv run pytest +``` + +### Code Quality + +```bash +# Type checking +uv run mypy . + +# Linting +uv run ruff check . + +# Formatting +uv run ruff format . +``` + +## License + +MIT License - see LICENSE file for details. diff --git a/README.rst b/README.rst deleted file mode 100644 index ea27f2f..0000000 --- a/README.rst +++ /dev/null @@ -1,63 +0,0 @@ -======== -Overview -======== - -**A Django app that can handle multiple ways of showing different notification types. It's all based on multiple input -types and output backends.** - - -Notifications -============= -A notification could be anything: - -- you have received a private message on a forum -- there is a new comment on your blog -- someone liked your profile or article -- a new post was created in a thread you follow -- someone answered your poll -- you have a new friend request or follower - -As far as this project is concerned, a notification is nothing more but an (optional) subject, text body, and a list of -receivers. - -Backends -======== -There are multiple output backends. Some possible examples are: - -- email -- sms message -- iPhone push notification -- notification center - -At this moment only two email backends are provided. - -Notification types -================== -A notification type is the glue between a message (input) and one or more possible backends (output). For example, you -might want to send all account related messages to email only, but notifications about new private messages could go to -email, iPhone push messages, Django's own messages app, you name it. - -Each notification type can specify its allowed backend(s), and each user can specify his preferred output backend(s). -Each notification will then figure out what backend to use based on this information. - -Settings -======== -Some backends will need extra information from the user, for example a phone number or email address. - -Users can also select which notification types they're interested in, and what possible backends they would like to -receive the message on. - -Queue -===== -Most notification backends can't process in real time, instead adding them to a queue. At this moment, this is based on -a simple database model and a manage.py script which can be used from your cron. - -In the future celery tasks should be added too. - -Installation -============ -See `INSTALL.rst` - -Usage -===== -See `USAGE.rst` for examples diff --git a/USAGE.rst b/USAGE.rst deleted file mode 100644 index 2685550..0000000 --- a/USAGE.rst +++ /dev/null @@ -1,12 +0,0 @@ -Usage -===== -Simple example of use cases:: - - from notifications.type.default import DefaultNotification - DefaultNotification('Subject', 'This is a notification!', request=request).send() - - from notifications.type.account import AccountNotification - AccountNotification('Account created', 'Your account has been created!', user=request.user).send() - - # Your own subclass, which overrides get_subject, get_text and get_recipients - NewForumReplyNotification(post=forumpost).send() diff --git a/example/.vscode/settings.json b/example/.vscode/settings.json new file mode 100644 index 0000000..c638bc1 --- /dev/null +++ b/example/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "[html]": { + "editor.defaultFormatter": "monosans.djlint", + "editor.indentSize": 2 + }, + "djlint.profile": "django", + "djlint.enableLinting": false +} diff --git a/notifications/migrations/__init__.py b/example/config/__init__.py similarity index 100% rename from notifications/migrations/__init__.py rename to example/config/__init__.py diff --git a/example/config/asgi.py b/example/config/asgi.py new file mode 100644 index 0000000..dbfbf6a --- /dev/null +++ b/example/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for example project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_asgi_application() diff --git a/example/config/middleware.py b/example/config/middleware.py new file mode 100644 index 0000000..4af7fc5 --- /dev/null +++ b/example/config/middleware.py @@ -0,0 +1,23 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser + +User = get_user_model() + + +class AutoLoginMiddleware: + """Automatically logs in the default user for example app.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if isinstance(request.user, AnonymousUser): + try: + default_user = User.objects.get(username="demo") + request.user = default_user + request._cached_user = default_user + except User.DoesNotExist: + pass + + response = self.get_response(request) + return response diff --git a/example/config/settings.py b/example/config/settings.py new file mode 100644 index 0000000..7f373b2 --- /dev/null +++ b/example/config/settings.py @@ -0,0 +1,129 @@ +""" +Django settings for exampleproject project. + +Generated by 'django-admin startproject' using Django 5.2.4. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-lbvqw!+fon44eta3qj*^tin%$fy-p@4cvut)hpbdri#s67kuly" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ["*"] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "generic_notifications", + "notifications", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "config.middleware.AutoLoginMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "notifications.context_processors.notifications", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Email backend for development │ │ +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" diff --git a/example/config/urls.py b/example/config/urls.py new file mode 100644 index 0000000..9be7f3d --- /dev/null +++ b/example/config/urls.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", include("notifications.urls")), +] diff --git a/example/config/wsgi.py b/example/config/wsgi.py new file mode 100644 index 0000000..f9d2a31 --- /dev/null +++ b/example/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for example project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() diff --git a/example/manage.py b/example/manage.py new file mode 100755 index 0000000..aabb818 --- /dev/null +++ b/example/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/example/notifications/__init__.py b/example/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example/notifications/admin.py b/example/notifications/admin.py new file mode 100644 index 0000000..035e366 --- /dev/null +++ b/example/notifications/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin +from generic_notifications.models import DisabledNotificationTypeChannel, EmailFrequency, Notification + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ["recipient", "notification_type", "added", "channels"] + + +@admin.register(DisabledNotificationTypeChannel) +class DisabledNotificationTypeChannelAdmin(admin.ModelAdmin): + list_display = ["user", "notification_type", "channel"] + + +@admin.register(EmailFrequency) +class EmailFrequencyAdmin(admin.ModelAdmin): + list_display = ["user", "notification_type", "frequency"] diff --git a/example/notifications/context_processors.py b/example/notifications/context_processors.py new file mode 100644 index 0000000..eea1472 --- /dev/null +++ b/example/notifications/context_processors.py @@ -0,0 +1,5 @@ +from generic_notifications.utils import get_unread_count + + +def notifications(request): + return {"unread_notifications": get_unread_count(request.user)} diff --git a/example/notifications/migrations/0001_create_default_user.py b/example/notifications/migrations/0001_create_default_user.py new file mode 100644 index 0000000..9b3c5ac --- /dev/null +++ b/example/notifications/migrations/0001_create_default_user.py @@ -0,0 +1,31 @@ +from django.contrib.auth import get_user_model +from django.db import migrations + +User = get_user_model() + + +def create_default_user(apps, schema_editor): + if not User.objects.filter(username="demo").exists(): + User.objects.create_user( + username="demo", + email="demo@example.com", + password="demo", + first_name="Demo", + last_name="User", + is_staff=True, + is_superuser=True, + ) + + +def delete_default_user(apps, schema_editor): + User.objects.filter(username="demo").delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.RunPython(create_default_user, delete_default_user), + ] diff --git a/example/notifications/migrations/__init__.py b/example/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example/notifications/templates/home.html b/example/notifications/templates/home.html new file mode 100644 index 0000000..649adfa --- /dev/null +++ b/example/notifications/templates/home.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %} + Django Generic Notifications Example +{% endblock title %} + +{% block body %} +
+
+
+

Welcome!

+

+ This is an example app demonstrating the django-generic-notifications + package. You can send test notifications, view them in the notification + center, and configure your preferences. +

+ +
+ {% csrf_token %} + +
+
+
+
+{% endblock body %} diff --git a/example/notifications/templates/notifications.html b/example/notifications/templates/notifications.html new file mode 100644 index 0000000..6706a0f --- /dev/null +++ b/example/notifications/templates/notifications.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %} + Notifications +{% endblock title %} + +{% block body %} +
+

Notifications

+
+ +
+
+
+ Unread + Archive +
+ +
+ {% if not archive and notifications %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
+ + {% if notifications %} +
+ + + {% for notification in notifications %} + + + + + + {% endfor %} + +
+

+ {% if notification.url %} + {{ notification.get_text }} + {% elif notification.target.get_absolute_url %} + {{ notification.get_text }} + {% else %} + {{ notification.get_text }} + {% endif %} +

+
{{ notification.added|date:"M d, Y H:i" }} +
+ {% csrf_token %} + + {% if archive %} + + {% else %} + + {% endif %} +
+
+
+ {% else %} +
+

+ {% if archive %} + No archived notifications + {% else %} + No unread notifications + {% endif %} +

+
+ {% endif %} +
+{% endblock body %} diff --git a/example/notifications/templates/settings.html b/example/notifications/templates/settings.html new file mode 100644 index 0000000..d2a39c4 --- /dev/null +++ b/example/notifications/templates/settings.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% load static notification_utils %} + +{% block title %} + Notification settings +{% endblock title %} + +{% block body %} +
+

Notification settings

+

Configure how you wish to receive notifications.

+
+ +
+ {% csrf_token %} + +
+ + + + + {% for channel_key, channel in channels.items %}{% endfor %} + {% if "email" in channels %}{% endif %} + + + + + {% for type_data in settings_data %} + + + + + + {% for channel_key, channel in channels.items %} + + {% endfor %} + + + {% if "email" in channels %} + + {% endif %} + + {% endfor %} + +
Notification Type{{ channel.name }}Email Frequency
+
{{ type_data.notification_type.name }}
+
{{ type_data.notification_type.description }}
+
+ {% with channel_data=type_data.channels|dict_get:channel_key %} + + {% endwith %} + + +
+
+ + +
+{% endblock body %} diff --git a/example/notifications/templatetags/__init__.py b/example/notifications/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example/notifications/templatetags/notification_utils.py b/example/notifications/templatetags/notification_utils.py new file mode 100644 index 0000000..d7b2e1f --- /dev/null +++ b/example/notifications/templatetags/notification_utils.py @@ -0,0 +1,9 @@ +from django import template + +register = template.Library() + + +@register.filter +def dict_get(dictionary, key): + """Get a value from a dictionary by key.""" + return dictionary.get(key) diff --git a/example/notifications/types.py b/example/notifications/types.py new file mode 100644 index 0000000..fd6d473 --- /dev/null +++ b/example/notifications/types.py @@ -0,0 +1,8 @@ +from generic_notifications.types import NotificationType, register + + +@register +class CommentNotificationType(NotificationType): + key = "comment_notification" + name = "Comments" + description = "You received a comment" diff --git a/example/notifications/urls.py b/example/notifications/urls.py new file mode 100644 index 0000000..edda453 --- /dev/null +++ b/example/notifications/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("", views.HomeView.as_view(), name="home"), + path("notifications/", views.NotificationsView.as_view(), name="notifications"), + path("notifications/archive/", views.NotificationsArchiveView.as_view(), name="notifications-archive"), + path("notifications/settings/", views.NotificationSettingsView.as_view(), name="notification-settings"), +] diff --git a/example/notifications/views.py b/example/notifications/views.py new file mode 100644 index 0000000..cea5d53 --- /dev/null +++ b/example/notifications/views.py @@ -0,0 +1,160 @@ +from typing import Any, Dict + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.views.generic import View +from generic_notifications import send_notification +from generic_notifications.models import DisabledNotificationTypeChannel, EmailFrequency +from generic_notifications.registry import registry +from generic_notifications.utils import get_notifications, mark_notifications_as_read + +from .types import CommentNotificationType + + +class HomeView(LoginRequiredMixin, View): + def get(self, request): + return TemplateResponse(request, "home.html") + + def post(self, request): + # Send a test notification + send_notification( + recipient=request.user, + notification_type=CommentNotificationType, + subject="Test Notification", + text="You received a new comment!", + ) + + return redirect("home") + + +class NotificationsView(LoginRequiredMixin, View): + def get(self, request): + notifications = get_notifications(user=request.user, unread_only=True) + context = { + "notifications": notifications, + "archive": False, + } + return TemplateResponse(request, "notifications.html", context=context) + + def post(self, request): + id = request.POST.get("id") + if id: + notifications = get_notifications(user=request.user) + notification = notifications.get(id=id) + notification.mark_as_read() + else: + mark_notifications_as_read(user=request.user) + + return redirect("notifications") + + +class NotificationsArchiveView(LoginRequiredMixin, View): + def get(self, request): + notifications = get_notifications(user=request.user, unread_only=False).filter(read__isnull=False) + context = { + "notifications": notifications, + "archive": True, + } + return TemplateResponse(request, "notifications.html", context=context) + + def post(self, request): + id = request.POST.get("id") + if id: + notifications = get_notifications(user=request.user) + notification = notifications.get(id=id) + notification.mark_as_unread() + + return redirect("notifications-archive") + + +class NotificationSettingsView(LoginRequiredMixin, View): + def get(self, request): + # Get all registered notification types, channels, and frequencies + notification_types = {nt.key: nt for nt in registry.get_all_types()} + channels = {ch.key: ch for ch in registry.get_all_channels()} + frequencies = {freq.key: freq for freq in registry.get_all_frequencies()} + + # Get user's current disabled channels (opt-out system) + disabled_channels = set( + DisabledNotificationTypeChannel.objects.filter(user=request.user).values_list( + "notification_type", "channel" + ) + ) + + # Get user's email frequency preferences + email_frequencies = dict( + EmailFrequency.objects.filter(user=request.user).values_list("notification_type", "frequency") + ) + + # Build settings data structure for template + settings_data = [] + for notification_type in notification_types.values(): + type_key = notification_type.key + type_data: Dict[str, Any] = { + "notification_type": notification_type, + "channels": {}, + "email_frequency": email_frequencies.get(type_key, notification_type.default_email_frequency.key), + } + + for channel in channels.values(): + channel_key = channel.key + is_disabled = (type_key, channel_key) in disabled_channels + is_required = channel_key in [ch.key for ch in notification_type.required_channels] + + type_data["channels"][channel_key] = { + "channel": channel, + "enabled": is_required or not is_disabled, # Required channels are always enabled + "required": is_required, + } + + settings_data.append(type_data) + + context = { + "settings_data": settings_data, + "channels": channels, + "frequencies": frequencies, + } + return TemplateResponse(request, "settings.html", context=context) + + def post(self, request): + # Clear existing preferences to rebuild from form data + DisabledNotificationTypeChannel.objects.filter(user=request.user).delete() + EmailFrequency.objects.filter(user=request.user).delete() + + notification_types = {nt.key: nt for nt in registry.get_all_types()} + channels = {ch.key: ch for ch in registry.get_all_channels()} + frequencies = {freq.key: freq for freq in registry.get_all_frequencies()} + + # Process form data + for notification_type in notification_types.values(): + type_key = notification_type.key + + # Handle channel preferences + for channel in channels.values(): + channel_key = channel.key + form_key = f"{type_key}_{channel_key}" + + # Check if this channel is required (cannot be disabled) + if channel_key in [ch.key for ch in notification_type.required_channels]: + continue + + # If checkbox not checked, create disabled entry + if form_key not in request.POST: + DisabledNotificationTypeChannel.objects.create( + user=request.user, notification_type=type_key, channel=channel_key + ) + + # Handle email frequency preference + if "email" in [ch.key for ch in channels.values()]: + frequency_key = f"{type_key}_frequency" + if frequency_key in request.POST: + frequency_value = request.POST[frequency_key] + if frequency_value in frequencies: + # Only save if different from default + if frequency_value != notification_type.default_email_frequency.key: + EmailFrequency.objects.create( + user=request.user, notification_type=type_key, frequency=frequency_value + ) + + return redirect("notification-settings") diff --git a/example/pyproject.toml b/example/pyproject.toml new file mode 100644 index 0000000..f6728fc --- /dev/null +++ b/example/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "django-generic-notifications-example" +version = "1.0.0" +description = "Example app for django-generic-notifications" +requires-python = ">=3.10" +dependencies = [ + "django>=5.0", + "django-generic-notifications", +] + +[tool.uv] +package = false + +[tool.uv.sources] +django-generic-notifications = { path = "../" } + +[dependency-groups] +dev = [ + "djlint>=1.36.4", + "ruff>=0.11.7", +] + +[tool.ruff] +line-length = 120 +lint.extend-select = ["I", "N"] + +[tool.djlint] +profile = "django" +max_attribute_length = 140 +max_blank_lines = 1 +max_line_length = 140 +indent = 2 +custom_blocks = "partialdef,slot,element,setvar" +ignore="H006,H021,H030,H031" \ No newline at end of file diff --git a/example/templates/base.html b/example/templates/base.html new file mode 100644 index 0000000..544a952 --- /dev/null +++ b/example/templates/base.html @@ -0,0 +1,42 @@ + + + + + + + {% block title %} + Django Generic Notifications Example + {% endblock title %} + + + + + + + + +
+ {% block body %} + {% endblock body %} +
+ + diff --git a/example/uv.lock b/example/uv.lock new file mode 100644 index 0000000..310e129 --- /dev/null +++ b/example/uv.lock @@ -0,0 +1,423 @@ +version = 1 +revision = 2 +requires-python = ">=3.10" + +[[package]] +name = "asgiref" +version = "3.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/61/0aa957eec22ff70b830b22ff91f825e70e1ef732c06666a805730f28b36b/asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142", size = 36870, upload-time = "2025-07-08T09:07:43.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/3c/0464dcada90d5da0e71018c04a140ad6349558afb30b3051b4264cc5b965/asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c", size = 23790, upload-time = "2025-07-08T09:07:41.548Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cssbeautifier" +version = "1.15.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "editorconfig" }, + { name = "jsbeautifier" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/01/fdf41c1e5f93d359681976ba10410a04b299d248e28ecce1d4e88588dde4/cssbeautifier-1.15.4.tar.gz", hash = "sha256:9bb08dc3f64c101a01677f128acf01905914cf406baf87434dcde05b74c0acf5", size = 25376, upload-time = "2025-02-27T17:53:51.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/51/ef6c5628e46092f0a54c7cee69acc827adc6b6aab57b55d344fefbdf28f1/cssbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:78c84d5e5378df7d08622bbd0477a1abdbd209680e95480bf22f12d5701efc98", size = 123667, upload-time = "2025-02-27T17:53:43.594Z" }, +] + +[[package]] +name = "django" +version = "5.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/7e/034f0f9fb10c029a02daaf44d364d6bf2eced8c73f0d38c69da359d26b01/django-5.2.4.tar.gz", hash = "sha256:a1228c384f8fa13eebc015196db7b3e08722c5058d4758d20cb287503a540d8f", size = 10831909, upload-time = "2025-07-02T18:47:39.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/ae/706965237a672434c8b520e89a818e8b047af94e9beb342d0bee405c26c7/django-5.2.4-py3-none-any.whl", hash = "sha256:60c35bd96201b10c6e7a78121bd0da51084733efa303cc19ead021ab179cef5e", size = 8302187, upload-time = "2025-07-02T18:47:35.373Z" }, +] + +[[package]] +name = "django-generic-notifications" +version = "1.0.0" +source = { directory = "../" } +dependencies = [ + { name = "django" }, +] + +[package.metadata] +requires-dist = [{ name = "django", specifier = ">=3.2.0" }] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.15.0" }, + { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-django", specifier = ">=4.10.0" }, + { name = "ruff", specifier = ">=0.11.2" }, +] + +[[package]] +name = "django-generic-notifications-example" +version = "1.0.0" +source = { virtual = "." } +dependencies = [ + { name = "django" }, + { name = "django-generic-notifications" }, +] + +[package.dev-dependencies] +dev = [ + { name = "djlint" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = ">=5.0" }, + { name = "django-generic-notifications", directory = "../" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "djlint", specifier = ">=1.36.4" }, + { name = "ruff", specifier = ">=0.11.7" }, +] + +[[package]] +name = "djlint" +version = "1.36.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama" }, + { name = "cssbeautifier" }, + { name = "jsbeautifier" }, + { name = "json5" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tqdm" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/89/ecf5be9f5c59a0c53bcaa29671742c5e269cc7d0e2622e3f65f41df251bf/djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1", size = 47849, upload-time = "2024-12-24T13:06:36.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/71/6a3ce2b49a62e635b85dce30ccf3eb3a18fe79275d45535325a55a63d3a3/djlint-1.36.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2dfb60883ceb92465201bfd392291a7597c6752baede6fbb6f1980cac8d6c5c", size = 354135, upload-time = "2024-12-24T13:05:49.732Z" }, + { url = "https://files.pythonhosted.org/packages/72/47/308412dc579e277c910774f41b380308d582862b16763425583e69e0fc14/djlint-1.36.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4bc6a1320c0030244b530ac200642f883d3daa451a115920ef3d56d08b644292", size = 328501, upload-time = "2024-12-24T13:05:53.861Z" }, + { url = "https://files.pythonhosted.org/packages/9b/6f/428dc044d1e34363265b1301dc9b53253007acd858879d54b369d233aa96/djlint-1.36.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3164a048c7bb0baf042387b1e33f9bbbf99d90d1337bb4c3d66eb0f96f5400a1", size = 415849, upload-time = "2024-12-24T13:05:56.377Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/0d488e551d73ddf369552fc6f4c7702ea683e4bc1305bcf5c1d198fbdace/djlint-1.36.4-cp310-cp310-win_amd64.whl", hash = "sha256:3196d5277da5934962d67ad6c33a948ba77a7b6eadf064648bef6ee5f216b03c", size = 360969, upload-time = "2024-12-24T13:05:59.582Z" }, + { url = "https://files.pythonhosted.org/packages/04/68/18ecd1e4d54a523e1d077f01419d669116e5dede97f97f1eb8ddb918a872/djlint-1.36.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d68da0ed10ee9ca1e32e225cbb8e9b98bf7e6f8b48a8e4836117b6605b88cc7", size = 344261, upload-time = "2024-12-24T13:06:01.136Z" }, + { url = "https://files.pythonhosted.org/packages/1e/03/005cf5c66e57ca2d26249f8385bc64420b2a95fea81c5eb619c925199029/djlint-1.36.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c0478d5392247f1e6ee29220bbdbf7fb4e1bc0e7e83d291fda6fb926c1787ba7", size = 319580, upload-time = "2024-12-24T13:06:03.824Z" }, + { url = "https://files.pythonhosted.org/packages/9f/88/aea3c81343a273a87362f30442abc13351dc8ada0b10e51daa285b4dddac/djlint-1.36.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:962f7b83aee166e499eff916d631c6dde7f1447d7610785a60ed2a75a5763483", size = 407070, upload-time = "2024-12-24T13:06:05.356Z" }, + { url = "https://files.pythonhosted.org/packages/60/77/0f767ac0b72e9a664bb8c92b8940f21bc1b1e806e5bd727584d40a4ca551/djlint-1.36.4-cp311-cp311-win_amd64.whl", hash = "sha256:53cbc450aa425c832f09bc453b8a94a039d147b096740df54a3547fada77ed08", size = 360775, upload-time = "2024-12-24T13:06:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/53/f5/9ae02b875604755d4d00cebf96b218b0faa3198edc630f56a139581aed87/djlint-1.36.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff9faffd7d43ac20467493fa71d5355b5b330a00ade1c4d1e859022f4195223b", size = 354886, upload-time = "2024-12-24T13:06:11.571Z" }, + { url = "https://files.pythonhosted.org/packages/97/51/284443ff2f2a278f61d4ae6ae55eaf820ad9f0fd386d781cdfe91f4de495/djlint-1.36.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:79489e262b5ac23a8dfb7ca37f1eea979674cfc2d2644f7061d95bea12c38f7e", size = 323237, upload-time = "2024-12-24T13:06:13.057Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5e/791f4c5571f3f168ad26fa3757af8f7a05c623fde1134a9c4de814ee33b7/djlint-1.36.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e58c5fa8c6477144a0be0a87273706a059e6dd0d6efae01146ae8c29cdfca675", size = 411719, upload-time = "2024-12-24T13:06:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/1f/11/894425add6f84deffcc6e373f2ce250f2f7b01aa58c7f230016ebe7a0085/djlint-1.36.4-cp312-cp312-win_amd64.whl", hash = "sha256:bb6903777bf3124f5efedcddf1f4716aef097a7ec4223fc0fa54b865829a6e08", size = 362076, upload-time = "2024-12-24T13:06:17.517Z" }, + { url = "https://files.pythonhosted.org/packages/da/83/88b4c885812921739f5529a29085c3762705154d41caf7eb9a8886a3380c/djlint-1.36.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ead475013bcac46095b1bbc8cf97ed2f06e83422335734363f8a76b4ba7e47c2", size = 354384, upload-time = "2024-12-24T13:06:20.809Z" }, + { url = "https://files.pythonhosted.org/packages/32/38/67695f7a150b3d9d62fadb65242213d96024151570c3cf5d966effa68b0e/djlint-1.36.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c601dfa68ea253311deb4a29a7362b7a64933bdfcfb5a06618f3e70ad1fa835", size = 322971, upload-time = "2024-12-24T13:06:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7a/cd851393291b12e7fe17cf5d4d8874b8ea133aebbe9235f5314aabc96a52/djlint-1.36.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda5014f295002363381969864addeb2db13955f1b26e772657c3b273ed7809f", size = 410972, upload-time = "2024-12-24T13:06:24.077Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/56469120394b970d4f079a552fde21ed27702ca729595ab0ed459eb6d240/djlint-1.36.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ce37e085afe5a30953b2bd87cbe34c37843d94c701fc68a2dda06c1e428ff4", size = 362053, upload-time = "2024-12-24T13:06:25.432Z" }, + { url = "https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size = 52290, upload-time = "2024-12-24T13:06:33.76Z" }, +] + +[[package]] +name = "editorconfig" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/3a/a61d9a1f319a186b05d14df17daea42fcddea63c213bcd61a929fb3a6796/editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745", size = 14695, upload-time = "2025-06-09T08:21:37.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82", size = 16360, upload-time = "2025-06-09T08:21:35.654Z" }, +] + +[[package]] +name = "jsbeautifier" +version = "1.15.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "editorconfig" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592", size = 75257, upload-time = "2025-02-27T17:53:53.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/14/1c65fccf8413d5f5c6e8425f84675169654395098000d8bddc4e9d3390e1/jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528", size = 94707, upload-time = "2025-02-27T17:53:46.152Z" }, +] + +[[package]] +name = "json5" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/be/c6c745ec4c4539b25a278b70e29793f10382947df0d9efba2fa09120895d/json5-0.12.0.tar.gz", hash = "sha256:0b4b6ff56801a1c7dc817b0241bca4ce474a0e6a163bfef3fc594d3fd263ff3a", size = 51907, upload-time = "2025-04-03T16:33:13.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9f/3500910d5a98549e3098807493851eeef2b89cdd3032227558a104dfe926/json5-0.12.0-py3-none-any.whl", hash = "sha256:6d37aa6c08b0609f16e1ec5ff94697e2cbbfbad5ac112afa05794da9ab7810db", size = 36079, upload-time = "2025-04-03T16:33:11.927Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "regex" +version = "2025.7.34" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/de/e13fa6dc61d78b30ba47481f99933a3b49a57779d625c392d8036770a60d/regex-2025.7.34.tar.gz", hash = "sha256:9ead9765217afd04a86822dfcd4ed2747dfe426e887da413b15ff0ac2457e21a", size = 400714, upload-time = "2025-07-31T00:21:16.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/d2/0a44a9d92370e5e105f16669acf801b215107efea9dea4317fe96e9aad67/regex-2025.7.34-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d856164d25e2b3b07b779bfed813eb4b6b6ce73c2fd818d46f47c1eb5cd79bd6", size = 484591, upload-time = "2025-07-31T00:18:46.675Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b1/00c4f83aa902f1048495de9f2f33638ce970ce1cf9447b477d272a0e22bb/regex-2025.7.34-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d15a9da5fad793e35fb7be74eec450d968e05d2e294f3e0e77ab03fa7234a83", size = 289293, upload-time = "2025-07-31T00:18:53.069Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b0/5bc5c8ddc418e8be5530b43ae1f7c9303f43aeff5f40185c4287cf6732f2/regex-2025.7.34-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:95b4639c77d414efa93c8de14ce3f7965a94d007e068a94f9d4997bb9bd9c81f", size = 285932, upload-time = "2025-07-31T00:18:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/46/c7/a1a28d050b23665a5e1eeb4d7f13b83ea86f0bc018da7b8f89f86ff7f094/regex-2025.7.34-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7de1ceed5a5f84f342ba4a9f4ae589524adf9744b2ee61b5da884b5b659834", size = 780361, upload-time = "2025-07-31T00:18:56.13Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0d/82e7afe7b2c9fe3d488a6ab6145d1d97e55f822dfb9b4569aba2497e3d09/regex-2025.7.34-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02e5860a250cd350c4933cf376c3bc9cb28948e2c96a8bc042aee7b985cfa26f", size = 849176, upload-time = "2025-07-31T00:18:57.483Z" }, + { url = "https://files.pythonhosted.org/packages/bf/16/3036e16903d8194f1490af457a7e33b06d9e9edd9576b1fe6c7ac660e9ed/regex-2025.7.34-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a5966220b9a1a88691282b7e4350e9599cf65780ca60d914a798cb791aa1177", size = 897222, upload-time = "2025-07-31T00:18:58.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/010e089ae00d31418e7d2c6601760eea1957cde12be719730c7133b8c165/regex-2025.7.34-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48fb045bbd4aab2418dc1ba2088a5e32de4bfe64e1457b948bb328a8dc2f1c2e", size = 789831, upload-time = "2025-07-31T00:19:00.436Z" }, + { url = "https://files.pythonhosted.org/packages/dd/86/b312b7bf5c46d21dbd9a3fdc4a80fde56ea93c9c0b89cf401879635e094d/regex-2025.7.34-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:20ff8433fa45e131f7316594efe24d4679c5449c0ca69d91c2f9d21846fdf064", size = 780665, upload-time = "2025-07-31T00:19:01.828Z" }, + { url = "https://files.pythonhosted.org/packages/40/e5/674b82bfff112c820b09e3c86a423d4a568143ede7f8440fdcbce259e895/regex-2025.7.34-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c436fd1e95c04c19039668cfb548450a37c13f051e8659f40aed426e36b3765f", size = 773511, upload-time = "2025-07-31T00:19:03.654Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/39e7c578eb6cf1454db2b64e4733d7e4f179714867a75d84492ec44fa9b2/regex-2025.7.34-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0b85241d3cfb9f8a13cefdfbd58a2843f208f2ed2c88181bf84e22e0c7fc066d", size = 843990, upload-time = "2025-07-31T00:19:05.61Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/522a6715aefe2f463dc60c68924abeeb8ab6893f01adf5720359d94ede8c/regex-2025.7.34-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:075641c94126b064c65ab86e7e71fc3d63e7ff1bea1fb794f0773c97cdad3a03", size = 834676, upload-time = "2025-07-31T00:19:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/59/53/c4d5284cb40543566542e24f1badc9f72af68d01db21e89e36e02292eee0/regex-2025.7.34-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:70645cad3407d103d1dbcb4841839d2946f7d36cf38acbd40120fee1682151e5", size = 778420, upload-time = "2025-07-31T00:19:08.511Z" }, + { url = "https://files.pythonhosted.org/packages/ea/4a/b779a7707d4a44a7e6ee9d0d98e40b2a4de74d622966080e9c95e25e2d24/regex-2025.7.34-cp310-cp310-win32.whl", hash = "sha256:3b836eb4a95526b263c2a3359308600bd95ce7848ebd3c29af0c37c4f9627cd3", size = 263999, upload-time = "2025-07-31T00:19:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6e/33c7583f5427aa039c28bff7f4103c2de5b6aa5b9edc330c61ec576b1960/regex-2025.7.34-cp310-cp310-win_amd64.whl", hash = "sha256:cbfaa401d77334613cf434f723c7e8ba585df162be76474bccc53ae4e5520b3a", size = 276023, upload-time = "2025-07-31T00:19:11.34Z" }, + { url = "https://files.pythonhosted.org/packages/9f/fc/00b32e0ac14213d76d806d952826402b49fd06d42bfabacdf5d5d016bc47/regex-2025.7.34-cp310-cp310-win_arm64.whl", hash = "sha256:bca11d3c38a47c621769433c47f364b44e8043e0de8e482c5968b20ab90a3986", size = 268357, upload-time = "2025-07-31T00:19:12.729Z" }, + { url = "https://files.pythonhosted.org/packages/0d/85/f497b91577169472f7c1dc262a5ecc65e39e146fc3a52c571e5daaae4b7d/regex-2025.7.34-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da304313761b8500b8e175eb2040c4394a875837d5635f6256d6fa0377ad32c8", size = 484594, upload-time = "2025-07-31T00:19:13.927Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c5/ad2a5c11ce9e6257fcbfd6cd965d07502f6054aaa19d50a3d7fd991ec5d1/regex-2025.7.34-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:35e43ebf5b18cd751ea81455b19acfdec402e82fe0dc6143edfae4c5c4b3909a", size = 289294, upload-time = "2025-07-31T00:19:15.395Z" }, + { url = "https://files.pythonhosted.org/packages/8e/01/83ffd9641fcf5e018f9b51aa922c3e538ac9439424fda3df540b643ecf4f/regex-2025.7.34-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96bbae4c616726f4661fe7bcad5952e10d25d3c51ddc388189d8864fbc1b3c68", size = 285933, upload-time = "2025-07-31T00:19:16.704Z" }, + { url = "https://files.pythonhosted.org/packages/77/20/5edab2e5766f0259bc1da7381b07ce6eb4401b17b2254d02f492cd8a81a8/regex-2025.7.34-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9feab78a1ffa4f2b1e27b1bcdaad36f48c2fed4870264ce32f52a393db093c78", size = 792335, upload-time = "2025-07-31T00:19:18.561Z" }, + { url = "https://files.pythonhosted.org/packages/30/bd/744d3ed8777dce8487b2606b94925e207e7c5931d5870f47f5b643a4580a/regex-2025.7.34-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f14b36e6d4d07f1a5060f28ef3b3561c5d95eb0651741474ce4c0a4c56ba8719", size = 858605, upload-time = "2025-07-31T00:19:20.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/3d/93754176289718d7578c31d151047e7b8acc7a8c20e7706716f23c49e45e/regex-2025.7.34-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85c3a958ef8b3d5079c763477e1f09e89d13ad22198a37e9d7b26b4b17438b33", size = 905780, upload-time = "2025-07-31T00:19:21.876Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2e/c689f274a92deffa03999a430505ff2aeace408fd681a90eafa92fdd6930/regex-2025.7.34-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37555e4ae0b93358fa7c2d240a4291d4a4227cc7c607d8f85596cdb08ec0a083", size = 798868, upload-time = "2025-07-31T00:19:23.222Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/39673688805d139b33b4a24851a71b9978d61915c4d72b5ffda324d0668a/regex-2025.7.34-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee38926f31f1aa61b0232a3a11b83461f7807661c062df9eb88769d86e6195c3", size = 781784, upload-time = "2025-07-31T00:19:24.59Z" }, + { url = "https://files.pythonhosted.org/packages/18/bd/4c1cab12cfabe14beaa076523056b8ab0c882a8feaf0a6f48b0a75dab9ed/regex-2025.7.34-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a664291c31cae9c4a30589bd8bc2ebb56ef880c9c6264cb7643633831e606a4d", size = 852837, upload-time = "2025-07-31T00:19:25.911Z" }, + { url = "https://files.pythonhosted.org/packages/cb/21/663d983cbb3bba537fc213a579abbd0f263fb28271c514123f3c547ab917/regex-2025.7.34-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f3e5c1e0925e77ec46ddc736b756a6da50d4df4ee3f69536ffb2373460e2dafd", size = 844240, upload-time = "2025-07-31T00:19:27.688Z" }, + { url = "https://files.pythonhosted.org/packages/8e/2d/9beeeb913bc5d32faa913cf8c47e968da936af61ec20af5d269d0f84a100/regex-2025.7.34-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d428fc7731dcbb4e2ffe43aeb8f90775ad155e7db4347a639768bc6cd2df881a", size = 787139, upload-time = "2025-07-31T00:19:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f5/9b9384415fdc533551be2ba805dd8c4621873e5df69c958f403bfd3b2b6e/regex-2025.7.34-cp311-cp311-win32.whl", hash = "sha256:e154a7ee7fa18333ad90b20e16ef84daaeac61877c8ef942ec8dfa50dc38b7a1", size = 264019, upload-time = "2025-07-31T00:19:31.129Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/e069ed94debcf4cc9626d652a48040b079ce34c7e4fb174f16874958d485/regex-2025.7.34-cp311-cp311-win_amd64.whl", hash = "sha256:24257953d5c1d6d3c129ab03414c07fc1a47833c9165d49b954190b2b7f21a1a", size = 276047, upload-time = "2025-07-31T00:19:32.497Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/3bafbe9d1fd1db77355e7fbbbf0d0cfb34501a8b8e334deca14f94c7b315/regex-2025.7.34-cp311-cp311-win_arm64.whl", hash = "sha256:3157aa512b9e606586900888cd469a444f9b898ecb7f8931996cb715f77477f0", size = 268362, upload-time = "2025-07-31T00:19:34.094Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f0/31d62596c75a33f979317658e8d261574785c6cd8672c06741ce2e2e2070/regex-2025.7.34-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7f7211a746aced993bef487de69307a38c5ddd79257d7be83f7b202cb59ddb50", size = 485492, upload-time = "2025-07-31T00:19:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/d8/16/b818d223f1c9758c3434be89aa1a01aae798e0e0df36c1f143d1963dd1ee/regex-2025.7.34-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fb31080f2bd0681484b275461b202b5ad182f52c9ec606052020fe13eb13a72f", size = 290000, upload-time = "2025-07-31T00:19:37.175Z" }, + { url = "https://files.pythonhosted.org/packages/cd/70/69506d53397b4bd6954061bae75677ad34deb7f6ca3ba199660d6f728ff5/regex-2025.7.34-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0200a5150c4cf61e407038f4b4d5cdad13e86345dac29ff9dab3d75d905cf130", size = 286072, upload-time = "2025-07-31T00:19:38.612Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/536a216d5f66084fb577bb0543b5cb7de3272eb70a157f0c3a542f1c2551/regex-2025.7.34-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:739a74970e736df0773788377969c9fea3876c2fc13d0563f98e5503e5185f46", size = 797341, upload-time = "2025-07-31T00:19:40.119Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/733f8168449e56e8f404bb807ea7189f59507cbea1b67a7bbcd92f8bf844/regex-2025.7.34-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4fef81b2f7ea6a2029161ed6dea9ae13834c28eb5a95b8771828194a026621e4", size = 862556, upload-time = "2025-07-31T00:19:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/19/dd/59c464d58c06c4f7d87de4ab1f590e430821345a40c5d345d449a636d15f/regex-2025.7.34-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea74cf81fe61a7e9d77989050d0089a927ab758c29dac4e8e1b6c06fccf3ebf0", size = 910762, upload-time = "2025-07-31T00:19:43Z" }, + { url = "https://files.pythonhosted.org/packages/37/a8/b05ccf33ceca0815a1e253693b2c86544932ebcc0049c16b0fbdf18b688b/regex-2025.7.34-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4636a7f3b65a5f340ed9ddf53585c42e3ff37101d383ed321bfe5660481744b", size = 801892, upload-time = "2025-07-31T00:19:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9a/b993cb2e634cc22810afd1652dba0cae156c40d4864285ff486c73cd1996/regex-2025.7.34-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cef962d7834437fe8d3da6f9bfc6f93f20f218266dcefec0560ed7765f5fe01", size = 786551, upload-time = "2025-07-31T00:19:46.127Z" }, + { url = "https://files.pythonhosted.org/packages/2d/79/7849d67910a0de4e26834b5bb816e028e35473f3d7ae563552ea04f58ca2/regex-2025.7.34-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:cbe1698e5b80298dbce8df4d8d1182279fbdaf1044e864cbc9d53c20e4a2be77", size = 856457, upload-time = "2025-07-31T00:19:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/91/c6/de516bc082524b27e45cb4f54e28bd800c01efb26d15646a65b87b13a91e/regex-2025.7.34-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:32b9f9bcf0f605eb094b08e8da72e44badabb63dde6b83bd530580b488d1c6da", size = 848902, upload-time = "2025-07-31T00:19:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/22/519ff8ba15f732db099b126f039586bd372da6cd4efb810d5d66a5daeda1/regex-2025.7.34-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:524c868ba527eab4e8744a9287809579f54ae8c62fbf07d62aacd89f6026b282", size = 788038, upload-time = "2025-07-31T00:19:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7d/aabb467d8f57d8149895d133c88eb809a1a6a0fe262c1d508eb9dfabb6f9/regex-2025.7.34-cp312-cp312-win32.whl", hash = "sha256:d600e58ee6d036081c89696d2bdd55d507498a7180df2e19945c6642fac59588", size = 264417, upload-time = "2025-07-31T00:19:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/3b/39/bd922b55a4fc5ad5c13753274e5b536f5b06ec8eb9747675668491c7ab7a/regex-2025.7.34-cp312-cp312-win_amd64.whl", hash = "sha256:9a9ab52a466a9b4b91564437b36417b76033e8778e5af8f36be835d8cb370d62", size = 275387, upload-time = "2025-07-31T00:19:53.593Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/c61d2fdcecb754a40475a3d1ef9a000911d3e3fc75c096acf44b0dfb786a/regex-2025.7.34-cp312-cp312-win_arm64.whl", hash = "sha256:c83aec91af9c6fbf7c743274fd952272403ad9a9db05fe9bfc9df8d12b45f176", size = 268482, upload-time = "2025-07-31T00:19:55.183Z" }, + { url = "https://files.pythonhosted.org/packages/15/16/b709b2119975035169a25aa8e4940ca177b1a2e25e14f8d996d09130368e/regex-2025.7.34-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3c9740a77aeef3f5e3aaab92403946a8d34437db930a0280e7e81ddcada61f5", size = 485334, upload-time = "2025-07-31T00:19:56.58Z" }, + { url = "https://files.pythonhosted.org/packages/94/a6/c09136046be0595f0331bc58a0e5f89c2d324cf734e0b0ec53cf4b12a636/regex-2025.7.34-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:69ed3bc611540f2ea70a4080f853741ec698be556b1df404599f8724690edbcd", size = 289942, upload-time = "2025-07-31T00:19:57.943Z" }, + { url = "https://files.pythonhosted.org/packages/36/91/08fc0fd0f40bdfb0e0df4134ee37cfb16e66a1044ac56d36911fd01c69d2/regex-2025.7.34-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d03c6f9dcd562c56527c42b8530aad93193e0b3254a588be1f2ed378cdfdea1b", size = 285991, upload-time = "2025-07-31T00:19:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/99dc8f6f756606f0c214d14c7b6c17270b6bbe26d5c1f05cde9dbb1c551f/regex-2025.7.34-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6164b1d99dee1dfad33f301f174d8139d4368a9fb50bf0a3603b2eaf579963ad", size = 797415, upload-time = "2025-07-31T00:20:01.668Z" }, + { url = "https://files.pythonhosted.org/packages/62/cf/2fcdca1110495458ba4e95c52ce73b361cf1cafd8a53b5c31542cde9a15b/regex-2025.7.34-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1e4f4f62599b8142362f164ce776f19d79bdd21273e86920a7b604a4275b4f59", size = 862487, upload-time = "2025-07-31T00:20:03.142Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/899105dd27fed394e3fae45607c1983e138273ec167e47882fc401f112b9/regex-2025.7.34-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72a26dcc6a59c057b292f39d41465d8233a10fd69121fa24f8f43ec6294e5415", size = 910717, upload-time = "2025-07-31T00:20:04.727Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f6/4716198dbd0bcc9c45625ac4c81a435d1c4d8ad662e8576dac06bab35b17/regex-2025.7.34-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5273fddf7a3e602695c92716c420c377599ed3c853ea669c1fe26218867002f", size = 801943, upload-time = "2025-07-31T00:20:07.1Z" }, + { url = "https://files.pythonhosted.org/packages/40/5d/cff8896d27e4e3dd11dd72ac78797c7987eb50fe4debc2c0f2f1682eb06d/regex-2025.7.34-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c1844be23cd40135b3a5a4dd298e1e0c0cb36757364dd6cdc6025770363e06c1", size = 786664, upload-time = "2025-07-31T00:20:08.818Z" }, + { url = "https://files.pythonhosted.org/packages/10/29/758bf83cf7b4c34f07ac3423ea03cee3eb3176941641e4ccc05620f6c0b8/regex-2025.7.34-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dde35e2afbbe2272f8abee3b9fe6772d9b5a07d82607b5788e8508974059925c", size = 856457, upload-time = "2025-07-31T00:20:10.328Z" }, + { url = "https://files.pythonhosted.org/packages/d7/30/c19d212b619963c5b460bfed0ea69a092c6a43cba52a973d46c27b3e2975/regex-2025.7.34-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f6e8e7af516a7549412ce57613e859c3be27d55341a894aacaa11703a4c31a", size = 849008, upload-time = "2025-07-31T00:20:11.823Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b8/3c35da3b12c87e3cc00010ef6c3a4ae787cff0bc381aa3d251def219969a/regex-2025.7.34-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:469142fb94a869beb25b5f18ea87646d21def10fbacb0bcb749224f3509476f0", size = 788101, upload-time = "2025-07-31T00:20:13.729Z" }, + { url = "https://files.pythonhosted.org/packages/47/80/2f46677c0b3c2b723b2c358d19f9346e714113865da0f5f736ca1a883bde/regex-2025.7.34-cp313-cp313-win32.whl", hash = "sha256:da7507d083ee33ccea1310447410c27ca11fb9ef18c95899ca57ff60a7e4d8f1", size = 264401, upload-time = "2025-07-31T00:20:15.233Z" }, + { url = "https://files.pythonhosted.org/packages/be/fa/917d64dd074682606a003cba33585c28138c77d848ef72fc77cbb1183849/regex-2025.7.34-cp313-cp313-win_amd64.whl", hash = "sha256:9d644de5520441e5f7e2db63aec2748948cc39ed4d7a87fd5db578ea4043d997", size = 275368, upload-time = "2025-07-31T00:20:16.711Z" }, + { url = "https://files.pythonhosted.org/packages/65/cd/f94383666704170a2154a5df7b16be28f0c27a266bffcd843e58bc84120f/regex-2025.7.34-cp313-cp313-win_arm64.whl", hash = "sha256:7bf1c5503a9f2cbd2f52d7e260acb3131b07b6273c470abb78568174fe6bde3f", size = 268482, upload-time = "2025-07-31T00:20:18.189Z" }, + { url = "https://files.pythonhosted.org/packages/ac/23/6376f3a23cf2f3c00514b1cdd8c990afb4dfbac3cb4a68b633c6b7e2e307/regex-2025.7.34-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:8283afe7042d8270cecf27cca558873168e771183d4d593e3c5fe5f12402212a", size = 485385, upload-time = "2025-07-31T00:20:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/73/5b/6d4d3a0b4d312adbfd6d5694c8dddcf1396708976dd87e4d00af439d962b/regex-2025.7.34-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6c053f9647e3421dd2f5dff8172eb7b4eec129df9d1d2f7133a4386319b47435", size = 289788, upload-time = "2025-07-31T00:20:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/92/71/5862ac9913746e5054d01cb9fb8125b3d0802c0706ef547cae1e7f4428fa/regex-2025.7.34-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a16dd56bbcb7d10e62861c3cd000290ddff28ea142ffb5eb3470f183628011ac", size = 286136, upload-time = "2025-07-31T00:20:26.146Z" }, + { url = "https://files.pythonhosted.org/packages/27/df/5b505dc447eb71278eba10d5ec940769ca89c1af70f0468bfbcb98035dc2/regex-2025.7.34-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69c593ff5a24c0d5c1112b0df9b09eae42b33c014bdca7022d6523b210b69f72", size = 797753, upload-time = "2025-07-31T00:20:27.919Z" }, + { url = "https://files.pythonhosted.org/packages/86/38/3e3dc953d13998fa047e9a2414b556201dbd7147034fbac129392363253b/regex-2025.7.34-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98d0ce170fcde1a03b5df19c5650db22ab58af375aaa6ff07978a85c9f250f0e", size = 863263, upload-time = "2025-07-31T00:20:29.803Z" }, + { url = "https://files.pythonhosted.org/packages/68/e5/3ff66b29dde12f5b874dda2d9dec7245c2051f2528d8c2a797901497f140/regex-2025.7.34-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d72765a4bff8c43711d5b0f5b452991a9947853dfa471972169b3cc0ba1d0751", size = 910103, upload-time = "2025-07-31T00:20:31.313Z" }, + { url = "https://files.pythonhosted.org/packages/9e/fe/14176f2182125977fba3711adea73f472a11f3f9288c1317c59cd16ad5e6/regex-2025.7.34-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4494f8fd95a77eb434039ad8460e64d57baa0434f1395b7da44015bef650d0e4", size = 801709, upload-time = "2025-07-31T00:20:33.323Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0d/80d4e66ed24f1ba876a9e8e31b709f9fd22d5c266bf5f3ab3c1afe683d7d/regex-2025.7.34-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4f42b522259c66e918a0121a12429b2abcf696c6f967fa37bdc7b72e61469f98", size = 786726, upload-time = "2025-07-31T00:20:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/c3ebb30e04a56c046f5c85179dc173818551037daae2c0c940c7b19152cb/regex-2025.7.34-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:aaef1f056d96a0a5d53ad47d019d5b4c66fe4be2da87016e0d43b7242599ffc7", size = 857306, upload-time = "2025-07-31T00:20:37.12Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b2/a4dc5d8b14f90924f27f0ac4c4c4f5e195b723be98adecc884f6716614b6/regex-2025.7.34-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:656433e5b7dccc9bc0da6312da8eb897b81f5e560321ec413500e5367fcd5d47", size = 848494, upload-time = "2025-07-31T00:20:38.818Z" }, + { url = "https://files.pythonhosted.org/packages/0d/21/9ac6e07a4c5e8646a90b56b61f7e9dac11ae0747c857f91d3d2bc7c241d9/regex-2025.7.34-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e91eb2c62c39705e17b4d42d4b86c4e86c884c0d15d9c5a47d0835f8387add8e", size = 787850, upload-time = "2025-07-31T00:20:40.478Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/d51204e28e7bc54f9a03bb799b04730d7e54ff2718862b8d4e09e7110a6a/regex-2025.7.34-cp314-cp314-win32.whl", hash = "sha256:f978ddfb6216028c8f1d6b0f7ef779949498b64117fc35a939022f67f810bdcb", size = 269730, upload-time = "2025-07-31T00:20:42.253Z" }, + { url = "https://files.pythonhosted.org/packages/74/52/a7e92d02fa1fdef59d113098cb9f02c5d03289a0e9f9e5d4d6acccd10677/regex-2025.7.34-cp314-cp314-win_amd64.whl", hash = "sha256:4b7dc33b9b48fb37ead12ffc7bdb846ac72f99a80373c4da48f64b373a7abeae", size = 278640, upload-time = "2025-07-31T00:20:44.42Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/a815529b559b1771080faa90c3ab401730661f99d495ab0071649f139ebd/regex-2025.7.34-cp314-cp314-win_arm64.whl", hash = "sha256:4b8c4d39f451e64809912c82392933d80fe2e4a87eeef8859fcc5380d0173c64", size = 271757, upload-time = "2025-07-31T00:20:46.355Z" }, +] + +[[package]] +name = "ruff" +version = "0.12.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/81/0bd3594fa0f690466e41bd033bdcdf86cba8288345ac77ad4afbe5ec743a/ruff-0.12.7.tar.gz", hash = "sha256:1fc3193f238bc2d7968772c82831a4ff69252f673be371fb49663f0068b7ec71", size = 5197814, upload-time = "2025-07-29T22:32:35.877Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/d2/6cb35e9c85e7a91e8d22ab32ae07ac39cc34a71f1009a6f9e4a2a019e602/ruff-0.12.7-py3-none-linux_armv6l.whl", hash = "sha256:76e4f31529899b8c434c3c1dede98c4483b89590e15fb49f2d46183801565303", size = 11852189, upload-time = "2025-07-29T22:31:41.281Z" }, + { url = "https://files.pythonhosted.org/packages/63/5b/a4136b9921aa84638f1a6be7fb086f8cad0fde538ba76bda3682f2599a2f/ruff-0.12.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:789b7a03e72507c54fb3ba6209e4bb36517b90f1a3569ea17084e3fd295500fb", size = 12519389, upload-time = "2025-07-29T22:31:54.265Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c9/3e24a8472484269b6b1821794141f879c54645a111ded4b6f58f9ab0705f/ruff-0.12.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e1c2a3b8626339bb6369116e7030a4cf194ea48f49b64bb505732a7fce4f4e3", size = 11743384, upload-time = "2025-07-29T22:31:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/26/7c/458dd25deeb3452c43eaee853c0b17a1e84169f8021a26d500ead77964fd/ruff-0.12.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32dec41817623d388e645612ec70d5757a6d9c035f3744a52c7b195a57e03860", size = 11943759, upload-time = "2025-07-29T22:32:01.95Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8b/658798472ef260ca050e400ab96ef7e85c366c39cf3dfbef4d0a46a528b6/ruff-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47ef751f722053a5df5fa48d412dbb54d41ab9b17875c6840a58ec63ff0c247c", size = 11654028, upload-time = "2025-07-29T22:32:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/a8/86/9c2336f13b2a3326d06d39178fd3448dcc7025f82514d1b15816fe42bfe8/ruff-0.12.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a828a5fc25a3efd3e1ff7b241fd392686c9386f20e5ac90aa9234a5faa12c423", size = 13225209, upload-time = "2025-07-29T22:32:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/76/69/df73f65f53d6c463b19b6b312fd2391dc36425d926ec237a7ed028a90fc1/ruff-0.12.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5726f59b171111fa6a69d82aef48f00b56598b03a22f0f4170664ff4d8298efb", size = 14182353, upload-time = "2025-07-29T22:32:10.053Z" }, + { url = "https://files.pythonhosted.org/packages/58/1e/de6cda406d99fea84b66811c189b5ea139814b98125b052424b55d28a41c/ruff-0.12.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74e6f5c04c4dd4aba223f4fe6e7104f79e0eebf7d307e4f9b18c18362124bccd", size = 13631555, upload-time = "2025-07-29T22:32:12.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ae/625d46d5164a6cc9261945a5e89df24457dc8262539ace3ac36c40f0b51e/ruff-0.12.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d0bfe4e77fba61bf2ccadf8cf005d6133e3ce08793bbe870dd1c734f2699a3e", size = 12667556, upload-time = "2025-07-29T22:32:15.312Z" }, + { url = "https://files.pythonhosted.org/packages/55/bf/9cb1ea5e3066779e42ade8d0cd3d3b0582a5720a814ae1586f85014656b6/ruff-0.12.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06bfb01e1623bf7f59ea749a841da56f8f653d641bfd046edee32ede7ff6c606", size = 12939784, upload-time = "2025-07-29T22:32:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/55/7f/7ead2663be5627c04be83754c4f3096603bf5e99ed856c7cd29618c691bd/ruff-0.12.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e41df94a957d50083fd09b916d6e89e497246698c3f3d5c681c8b3e7b9bb4ac8", size = 11771356, upload-time = "2025-07-29T22:32:20.134Z" }, + { url = "https://files.pythonhosted.org/packages/17/40/a95352ea16edf78cd3a938085dccc55df692a4d8ba1b3af7accbe2c806b0/ruff-0.12.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4000623300563c709458d0ce170c3d0d788c23a058912f28bbadc6f905d67afa", size = 11612124, upload-time = "2025-07-29T22:32:22.645Z" }, + { url = "https://files.pythonhosted.org/packages/4d/74/633b04871c669e23b8917877e812376827c06df866e1677f15abfadc95cb/ruff-0.12.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:69ffe0e5f9b2cf2b8e289a3f8945b402a1b19eff24ec389f45f23c42a3dd6fb5", size = 12479945, upload-time = "2025-07-29T22:32:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/be/34/c3ef2d7799c9778b835a76189c6f53c179d3bdebc8c65288c29032e03613/ruff-0.12.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a07a5c8ffa2611a52732bdc67bf88e243abd84fe2d7f6daef3826b59abbfeda4", size = 12998677, upload-time = "2025-07-29T22:32:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/77/ab/aca2e756ad7b09b3d662a41773f3edcbd262872a4fc81f920dc1ffa44541/ruff-0.12.7-py3-none-win32.whl", hash = "sha256:c928f1b2ec59fb77dfdf70e0419408898b63998789cc98197e15f560b9e77f77", size = 11756687, upload-time = "2025-07-29T22:32:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/b4/71/26d45a5042bc71db22ddd8252ca9d01e9ca454f230e2996bb04f16d72799/ruff-0.12.7-py3-none-win_amd64.whl", hash = "sha256:9c18f3d707ee9edf89da76131956aba1270c6348bfee8f6c647de841eac7194f", size = 12912365, upload-time = "2025-07-29T22:32:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9b/0b8aa09817b63e78d94b4977f18b1fcaead3165a5ee49251c5d5c245bb2d/ruff-0.12.7-py3-none-win_arm64.whl", hash = "sha256:dfce05101dbd11833a0776716d5d1578641b7fddb537fe7fa956ab85d1769b69", size = 11982083, upload-time = "2025-07-29T22:32:33.881Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] diff --git a/generic_notifications/__init__.py b/generic_notifications/__init__.py new file mode 100644 index 0000000..c87d4cb --- /dev/null +++ b/generic_notifications/__init__.py @@ -0,0 +1,100 @@ +import logging +from typing import TYPE_CHECKING, Any + +from django.db import transaction + +if TYPE_CHECKING: + from .types import NotificationType + + +def send_notification( + recipient: Any, + notification_type: "type[NotificationType]", + actor: Any | None = None, + target: Any | None = None, + subject: str = "", + text: str = "", + url: str = "", + metadata: dict[str, Any] | None = None, + **kwargs, +): + """ + Send a notification to a user through all registered channels. + + Args: + recipient: User to send notification to + notification_type: Type of notification (must be registered in registry) + actor: User who triggered the notification (optional) + target: Object the notification is about (optional) + subject: Notification subject line (optional, can be generated by notification type) + text: Notification body text (optional, can be generated by notification type) + url: URL associated with the notification (optional) + metadata: Additional JSON-serializable data to store with the notification (optional) + **kwargs: Any additional fields for the notification model + + Returns: + Notification instance or None if no channels are enabled + + Raises: + ValueError: If notification_type is not registered + """ + from .models import Notification + from .registry import registry + + # Validate notification type is registered + try: + registry.get_type(notification_type.key) + except KeyError: + available_types = [t.key for t in registry.get_all_types()] + if available_types: + raise ValueError( + f"Notification type '{notification_type}' not registered. Available types: {available_types}" + ) + else: + raise ValueError( + f"Notification type '{notification_type}' not registered. No notification types are currently registered." + ) + + # Determine which channels are enabled for this user/notification type + enabled_channels = [] + enabled_channel_instances = [] + for channel_instance in registry.get_all_channels(): + if channel_instance.is_enabled(recipient, notification_type.key): + enabled_channels.append(channel_instance.key) + enabled_channel_instances.append(channel_instance) + + # Don't create notification if no channels are enabled + if not enabled_channels: + return None + + # Create the notification record with enabled channels + notification = Notification( + recipient=recipient, + notification_type=notification_type.key, + actor=actor, + target=target, + channels=enabled_channels, + subject=subject, + text=text, + url=url, + metadata=metadata or {}, + **kwargs, + ) + + # Use transaction to ensure atomicity when checking/updating existing notifications + with transaction.atomic(): + if notification_type.should_save(notification): + notification.save() + + # Process through enabled channels only + for channel_instance in enabled_channel_instances: + try: + channel_instance.process(notification) + except Exception as e: + # Log error but don't crash - other channels should still work + logger = logging.getLogger(__name__) + logger.error( + f"Channel {channel_instance.key} failed to process notification {notification.id}: {e}" + ) + + return notification diff --git a/generic_notifications/channels.py b/generic_notifications/channels.py new file mode 100644 index 0000000..6d6d3b3 --- /dev/null +++ b/generic_notifications/channels.py @@ -0,0 +1,276 @@ +import logging +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Type + +from django.conf import settings +from django.core.mail import send_mail +from django.db.models import QuerySet +from django.template.loader import render_to_string +from django.utils import timezone + +from .frequencies import DailyFrequency, NotificationFrequency +from .registry import registry + +if TYPE_CHECKING: + from .models import Notification + + +class NotificationChannel(ABC): + """ + Base class for all notification channels. + """ + + key: str + name: str + + @abstractmethod + def process(self, notification: "Notification") -> None: + """ + Process a notification through this channel. + + Args: + notification: Notification instance to process + """ + pass + + def is_enabled(self, user: Any, notification_type: str) -> bool: + """ + Check if user has this channel enabled for this notification type. + + Args: + user: User instance + notification_type: Notification type key + + Returns: + bool: True if enabled (default), False if disabled + """ + from .models import DisabledNotificationTypeChannel + + return not DisabledNotificationTypeChannel.objects.filter( + user=user, notification_type=notification_type, channel=self.key + ).exists() + + +def register(cls: Type[NotificationChannel]) -> Type[NotificationChannel]: + """ + Decorator that registers a NotificationChannel subclass. + + Usage: + @register + class EmailChannel(NotificationChannel): + key = "email" + name = "Email" + + def process(self, notification): + # Send email + """ + # Register the class + registry.register_channel(cls) + + # Return the class unchanged + return cls + + +@register +class WebsiteChannel(NotificationChannel): + """ + Channel for displaying notifications on the website. + Notifications are stored in the database and displayed in the UI. + """ + + key = "website" + name = "Website" + + def process(self, notification: "Notification") -> None: + """ + Website notifications are just stored in DB - no additional processing needed. + The notification was already created before channels are processed. + """ + pass + + +@register +class EmailChannel(NotificationChannel): + """ + Channel for sending notifications via email. + Supports both realtime delivery and daily digest batching. + """ + + key = "email" + name = "Email" + + def process(self, notification: "Notification") -> None: + """ + Process email notification based on user's frequency preference. + + Args: + notification: Notification instance to process + """ + frequency = self.get_frequency(notification.recipient, notification.notification_type) + + # Send immediately if realtime, otherwise leave for digest + if frequency and frequency.is_realtime: + self.send_email_now(notification) + + def get_frequency(self, user: Any, notification_type: str) -> NotificationFrequency: + """ + Get the user's email frequency preference for this notification type. + + Args: + user: User instance + notification_type: Notification type key + + Returns: + NotificationFrequency: NotificationFrequency instance (defaults to notification type's default) + """ + from .models import EmailFrequency + + try: + email_frequency = EmailFrequency.objects.get(user=user, notification_type=notification_type) + return registry.get_frequency(email_frequency.frequency) + except (EmailFrequency.DoesNotExist, KeyError): + # Get the notification type's default frequency + try: + notification_type_obj = registry.get_type(notification_type) + return notification_type_obj.default_email_frequency() + except (KeyError, AttributeError): + # Fallback to realtime if notification type not found or no default + return DailyFrequency() + + def send_email_now(self, notification: "Notification") -> None: + """ + Send an individual email notification immediately. + + Args: + notification: Notification instance to send + """ + try: + context = { + "notification": notification, + "user": notification.recipient, + "actor": notification.actor, + "target": notification.target, + } + + subject_template = f"notifications/email/realtime/{notification.notification_type}_subject.txt" + html_template = f"notifications/email/realtime/{notification.notification_type}.html" + text_template = f"notifications/email/realtime/{notification.notification_type}.txt" + + # Load subject + try: + subject = render_to_string(subject_template, context).strip() + except Exception: + # Fallback to notification's subject + subject = notification.get_subject() + + # Load HTML message + try: + html_message = render_to_string(html_template, context) + except Exception: + html_message = None + + # Load plain text message + text_message: str + try: + text_message = render_to_string(text_template, context) + except Exception: + # Fallback to notification's text + text_message = notification.get_text() + + send_mail( + subject=subject, + message=text_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[notification.recipient.email], + html_message=html_message, + fail_silently=False, + ) + + # Mark as sent + notification.email_sent_at = timezone.now() + notification.save(update_fields=["email_sent_at"]) + + except Exception as e: + logger = logging.getLogger(__name__) + logger.error(f"Failed to send email for notification {notification.id}: {e}") + + @classmethod + def send_digest_emails( + cls, user: Any, notifications: "QuerySet[Notification]", frequency: NotificationFrequency | None = None + ): + """ + Send a digest email to a specific user with specific notifications. + This method is used by the management command. + + Args: + user: User instance + notifications: QuerySet of notifications to include in digest + frequency: The frequency for template context + """ + from .models import Notification + + if not notifications.exists(): + return + + try: + # Group notifications by type for better digest formatting + notifications_by_type: dict[str, list[Notification]] = {} + for notification in notifications: + if notification.notification_type not in notifications_by_type: + notifications_by_type[notification.notification_type] = [] + notifications_by_type[notification.notification_type].append(notification) + + context = { + "user": user, + "notifications": notifications, + "notifications_by_type": notifications_by_type, + "count": notifications.count(), + "frequency": frequency, + } + + subject_template = "notifications/email/digest/subject.txt" + html_template = "notifications/email/digest/message.html" + text_template = "notifications/email/digest/message.txt" + + # Load subject + try: + subject = render_to_string(subject_template, context).strip() + except Exception: + # Fallback subject + frequency_name = frequency.name if frequency else "Digest" + subject = f"{frequency_name} - {notifications.count()} new notifications" + + # Load HTML message + try: + html_message = render_to_string(html_template, context) + except Exception: + html_message = None + + # Load plain text message + text_message: str + try: + text_message = render_to_string(text_template, context) + except Exception: + # Fallback if template doesn't exist + message_lines = [f"You have {notifications.count()} new notifications:"] + for notification in notifications[:10]: # Limit to first 10 + message_lines.append(f"- {notification.get_subject()}") + if notifications.count() > 10: + message_lines.append(f"... and {notifications.count() - 10} more") + text_message = "\n".join(message_lines) + + send_mail( + subject=subject, + message=text_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) + + # Mark all as sent + notifications.update(email_sent_at=timezone.now()) + + except Exception as e: + logger = logging.getLogger(__name__) + logger.error(f"Failed to send digest email for user {user.id}: {e}") diff --git a/generic_notifications/frequencies.py b/generic_notifications/frequencies.py new file mode 100644 index 0000000..613ea5f --- /dev/null +++ b/generic_notifications/frequencies.py @@ -0,0 +1,53 @@ +from abc import ABC +from typing import Type + +from .registry import registry + + +class NotificationFrequency(ABC): + """ + Represents an email frequency option for notifications. + """ + + key: str + name: str + is_realtime: bool + description: str + + def __str__(self) -> str: + return self.name + + +def register(cls: Type[NotificationFrequency]) -> Type[NotificationFrequency]: + """ + Decorator that registers a NotificationFrequency subclass. + + Usage: + @register + class WeeklyFrequency(NotificationFrequency): + key = "weekly" + name = "Weekly digest" + is_realtime = False + description = "Bundle notifications into a weekly email" + """ + # Register the class + registry.register_frequency(cls) + + # Return the class unchanged + return cls + + +@register +class RealtimeFrequency(NotificationFrequency): + key = "realtime" + name = "Real-time" + is_realtime = True + description = "Send emails immediately when notifications are created" + + +@register +class DailyFrequency(NotificationFrequency): + key = "daily" + name = "Daily digest" + is_realtime = False + description = "Bundle notifications into a daily email" diff --git a/generic_notifications/management/__init__.py b/generic_notifications/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/generic_notifications/management/commands/__init__.py b/generic_notifications/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/generic_notifications/management/commands/send_digest_emails.py b/generic_notifications/management/commands/send_digest_emails.py new file mode 100644 index 0000000..f815b16 --- /dev/null +++ b/generic_notifications/management/commands/send_digest_emails.py @@ -0,0 +1,134 @@ +import logging + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + +from generic_notifications.channels import EmailChannel +from generic_notifications.models import Notification +from generic_notifications.registry import registry + +User = get_user_model() + + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Send digest emails to users who have opted for digest delivery" + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be sent without actually sending emails", + ) + parser.add_argument( + "--frequency", + type=str, + required=True, + help="Process specific frequency (e.g., daily, weekly)", + ) + + def handle(self, *args, **options): + dry_run = options["dry_run"] + target_frequency = options["frequency"] + + # In dry-run mode, temporarily set logger to INFO level for visibility + original_level = None + if dry_run: + original_level = logger.level + logger.setLevel(logging.INFO) + logger.info("DRY RUN - No emails will be sent") + + # Verify email channel is registered + try: + registry.get_channel(EmailChannel.key) + except KeyError: + logger.error("Email channel not registered") + return + + # Setup + email_channel = EmailChannel() + all_notification_types = registry.get_all_types() + + # Get the specific frequency (required argument) + try: + frequency = registry.get_frequency(target_frequency) + except KeyError: + logger.error(f"Frequency '{target_frequency}' not found") + return + + if frequency.is_realtime: + logger.error(f"Frequency '{target_frequency}' is realtime, not a digest frequency") + return + + total_emails_sent = 0 + + logger.info(f"Processing {frequency.name} digests...") + + # Find all users who have unsent, unread notifications for email channel + users_with_notifications = User.objects.filter( + notifications__email_sent_at__isnull=True, + notifications__read__isnull=True, + notifications__channels__icontains=f'"{EmailChannel.key}"', + ).distinct() + + for user in users_with_notifications: + # Determine which notification types should use this frequency for this user + relevant_types = self.get_notification_types_for_frequency( + user, + frequency.key, + all_notification_types, + email_channel, + ) + + if not relevant_types: + continue + + # Get unsent notifications for these types + # Exclude read notifications - don't email what user already saw on website + notifications = Notification.objects.filter( + recipient=user, + notification_type__in=relevant_types, + email_sent_at__isnull=True, + read__isnull=True, + channels__icontains=f'"{EmailChannel.key}"', + ).order_by("-added") + + if notifications.exists(): + logger.info(f" User {user.email}: {notifications.count()} notifications for {frequency.name} digest") + + if not dry_run: + EmailChannel.send_digest_emails(user, notifications, frequency) + + total_emails_sent += 1 + + # List notification subjects for debugging + for notification in notifications[:3]: # Show first 3 + logger.debug(f" - {notification.subject or notification.text[:30]}") + if notifications.count() > 3: + logger.debug(f" ... and {notifications.count() - 3} more") + + if dry_run: + logger.info(f"DRY RUN: Would have sent {total_emails_sent} digest emails") + # Restore original log level + if original_level is not None: + logger.setLevel(original_level) + else: + logger.info(f"Successfully sent {total_emails_sent} digest emails") + + def get_notification_types_for_frequency(self, user, frequency_key, all_notification_types, email_channel): + """ + Get all notification types that should use this frequency for the given user. + This includes both explicit preferences and types that default to this frequency. + Since notifications are only created for enabled channels, we don't need to check is_enabled. + """ + relevant_types = set() + + for notification_type in all_notification_types: + # Use EmailChannel's get_frequency method to get the frequency for this user/type + user_frequency = email_channel.get_frequency(user, notification_type.key) + if user_frequency.key == frequency_key: + relevant_types.add(notification_type.key) + + return list(relevant_types) diff --git a/generic_notifications/migrations/0001_initial.py b/generic_notifications/migrations/0001_initial.py new file mode 100644 index 0000000..423a773 --- /dev/null +++ b/generic_notifications/migrations/0001_initial.py @@ -0,0 +1,110 @@ +# Generated by Django 5.2.4 on 2025-07-24 12:47 + +import django.contrib.postgres.indexes +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DisabledNotificationTypeChannel", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("notification_type", models.CharField(max_length=50)), + ("channel", models.CharField(max_length=20)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="disabled_notification_type_channels", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("user", "notification_type", "channel")}, + }, + ), + migrations.CreateModel( + name="EmailFrequency", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("notification_type", models.CharField(max_length=50)), + ("frequency", models.CharField(max_length=20)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="email_frequencies", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("user", "notification_type")}, + }, + ), + migrations.CreateModel( + name="Notification", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("notification_type", models.CharField(max_length=50)), + ("added", models.DateTimeField(auto_now_add=True)), + ("read", models.DateTimeField(blank=True, null=True)), + ("subject", models.CharField(blank=True, max_length=255)), + ("text", models.TextField(blank=True)), + ("url", models.CharField(blank=True, max_length=500)), + ("object_id", models.PositiveIntegerField(blank=True, null=True)), + ("email_sent_at", models.DateTimeField(blank=True, null=True)), + ("channels", models.JSONField(blank=True, default=list)), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "actor", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="notifications_sent", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "content_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "recipient", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-added"], + "indexes": [ + django.contrib.postgres.indexes.GinIndex(fields=["channels"], name="notification_channels_gin"), + models.Index(fields=["recipient", "read", "channels"], name="notification_unread_channel"), + models.Index(fields=["recipient", "channels"], name="notification_recipient_channel"), + models.Index( + fields=["recipient", "email_sent_at", "read", "channels"], name="notification_user_email_digest" + ), + ], + }, + ), + ] diff --git a/generic_notifications/migrations/__init__.py b/generic_notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/generic_notifications/models.py b/generic_notifications/models.py new file mode 100644 index 0000000..19d0aee --- /dev/null +++ b/generic_notifications/models.py @@ -0,0 +1,226 @@ +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.indexes import GinIndex +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone + +from .channels import NotificationChannel, WebsiteChannel +from .registry import registry + +User = get_user_model() + + +class NotificationQuerySet(models.QuerySet): + """Custom QuerySet for optimized notification queries""" + + def prefetch(self): + """Prefetch related objects""" + return self.select_related("recipient", "actor") + + def for_channel(self, channel: type[NotificationChannel] = WebsiteChannel): + """Filter notifications by channel""" + return self.filter(channels__icontains=f'"{channel.key}"') + + def unread(self): + """Filter only unread notifications""" + return self.filter(read__isnull=True) + + +class DisabledNotificationTypeChannel(models.Model): + """ + If a row exists here, that notification type/channel combination is DISABLED for the user. + By default (no row), all notifications are enabled on all channels. + """ + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="disabled_notification_type_channels") + notification_type = models.CharField(max_length=50) + channel = models.CharField(max_length=20) + + class Meta: + unique_together = ["user", "notification_type", "channel"] + + def clean(self): + try: + notification_type_obj = registry.get_type(self.notification_type) + except KeyError: + available_types = [t.key for t in registry.get_all_types()] + if available_types: + raise ValidationError( + f"Unknown notification type: {self.notification_type}. Available types: {available_types}" + ) + else: + raise ValidationError( + f"Unknown notification type: {self.notification_type}. No notification types are currently registered." + ) + + # Check if trying to disable a required channel + required_channel_keys = [cls.key for cls in notification_type_obj.required_channels] + if self.channel in required_channel_keys: + raise ValidationError( + f"Cannot disable {self.channel} channel for {notification_type_obj.name} - this channel is required" + ) + + try: + registry.get_channel(self.channel) + except KeyError: + available_channels = [c.key for c in registry.get_all_channels()] + if available_channels: + raise ValidationError(f"Unknown channel: {self.channel}. Available channels: {available_channels}") + else: + raise ValidationError(f"Unknown channel: {self.channel}. No channels are currently registered.") + + def __str__(self) -> str: + return f"{self.user} disabled {self.notification_type} on {self.channel}" + + +class EmailFrequency(models.Model): + """ + Email delivery frequency preference per notification type. + Default is `NotificationType.default_email_frequency` if no row exists. + """ + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="email_frequencies") + notification_type = models.CharField(max_length=50) + frequency = models.CharField(max_length=20) + + class Meta: + unique_together = ["user", "notification_type"] + + def clean(self): + if self.notification_type: + try: + registry.get_type(self.notification_type) + except KeyError: + available_types = [t.key for t in registry.get_all_types()] + if available_types: + raise ValidationError( + f"Unknown notification type: {self.notification_type}. Available types: {available_types}" + ) + else: + raise ValidationError( + f"Unknown notification type: {self.notification_type}. No notification types are currently registered." + ) + + if self.frequency: + try: + registry.get_frequency(self.frequency) + except KeyError: + available_frequencies = [f.key for f in registry.get_all_frequencies()] + if available_frequencies: + raise ValidationError( + f"Unknown frequency: {self.frequency}. Available frequencies: {available_frequencies}" + ) + else: + raise ValidationError( + f"Unknown frequency: {self.frequency}. No frequencies are currently registered." + ) + + def __str__(self) -> str: + return f"{self.user} - {self.notification_type}: {self.frequency}" + + +class Notification(models.Model): + """ + A specific notification instance for a user + """ + + # Core fields + recipient = models.ForeignKey(User, on_delete=models.CASCADE, related_name="notifications") + notification_type = models.CharField(max_length=50) + added = models.DateTimeField(auto_now_add=True) + read = models.DateTimeField(null=True, blank=True) + + # Content fields + subject = models.CharField(max_length=255, blank=True) + text = models.TextField(blank=True) + url = models.CharField(max_length=500, blank=True) + + # Related data + actor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="notifications_sent") + + # Generic relation to link to any object (article, comment, etc) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True) + object_id = models.PositiveIntegerField(null=True, blank=True) + target = GenericForeignKey("content_type", "object_id") + + # Email tracking + email_sent_at = models.DateTimeField(null=True, blank=True) + + # Channels this notification is enabled for + channels = models.JSONField(default=list, blank=True) + + # Flexible metadata for any extra data + metadata = models.JSONField(default=dict, blank=True) + + objects = NotificationQuerySet.as_manager() + + class Meta: + indexes = [ + GinIndex(fields=["channels"], name="notification_channels_gin"), + models.Index(fields=["recipient", "read", "channels"], name="notification_unread_channel"), + models.Index(fields=["recipient", "channels"], name="notification_recipient_channel"), + models.Index( + fields=["recipient", "email_sent_at", "read", "channels"], name="notification_user_email_digest" + ), + ] + ordering = ["-added"] + + def clean(self) -> None: + if self.notification_type: + try: + registry.get_type(self.notification_type) + except KeyError: + available_types = [t.key for t in registry.get_all_types()] + if available_types: + raise ValidationError( + f"Unknown notification type: {self.notification_type}. Available types: {available_types}" + ) + else: + raise ValidationError( + f"Unknown notification type: {self.notification_type}. No notification types are currently registered." + ) + + def __str__(self) -> str: + return f"{self.notification_type} for {self.recipient}" + + def mark_as_read(self) -> None: + """Mark this notification as read""" + if not self.read: + self.read = timezone.now() + self.save(update_fields=["read"]) + + def mark_as_unread(self) -> None: + """Mark this notification as unread""" + if self.read: + self.read = None + self.save(update_fields=["read"]) + + def get_subject(self) -> str: + """Get the subject, using dynamic generation if not stored.""" + if self.subject: + return self.subject + + # Get the notification type and use its dynamic generation + try: + notification_type = registry.get_type(self.notification_type) + return notification_type.get_subject(self) or notification_type.description + except KeyError: + return f"Notification: {self.notification_type}" + + def get_text(self) -> str: + """Get the text, using dynamic generation if not stored.""" + if self.text: + return self.text + + # Get the notification type and use its dynamic generation + try: + notification_type = registry.get_type(self.notification_type) + return notification_type.get_text(self) + except KeyError: + return "You have a new notification" + + @property + def is_read(self) -> bool: + return self.read is not None diff --git a/generic_notifications/registry.py b/generic_notifications/registry.py new file mode 100644 index 0000000..bfe3e9e --- /dev/null +++ b/generic_notifications/registry.py @@ -0,0 +1,135 @@ +from typing import TYPE_CHECKING, Type + +if TYPE_CHECKING: + from .channels import NotificationChannel + from .frequencies import NotificationFrequency + from .types import NotificationType + + +class NotificationRegistry: + """ + Central registry for notification types, channels, and email frequencies. + Allows apps to register their own notification types and delivery channels. + """ + + def __init__(self) -> None: + self._type_classes: dict[str, Type["NotificationType"]] = {} + self._channel_classes: dict[str, Type["NotificationChannel"]] = {} + self._frequency_classes: dict[str, Type["NotificationFrequency"]] = {} + self._registered_class_ids: set[int] = set() + + def _register(self, cls, base_class, registry_dict: dict, class_type_name: str, force: bool = False) -> None: + """Generic registration method for all registry types""" + class_id = id(cls) + if class_id in self._registered_class_ids and not force: + return # Already registered this class + + try: + if not issubclass(cls, base_class): + raise ValueError(f"Must register a {class_type_name} subclass") + except TypeError: + raise ValueError(f"Must register a {class_type_name} subclass") + + if not hasattr(cls, "key") or not cls.key: + raise ValueError(f"{class_type_name} class must have a key attribute") + + registry_dict[cls.key] = cls + self._registered_class_ids.add(class_id) + + def register_type(self, notification_type_class: Type["NotificationType"], force: bool = False) -> None: + """Register a notification type class""" + from .types import NotificationType + + self._register(notification_type_class, NotificationType, self._type_classes, "NotificationType", force) + + def register_channel(self, channel_class: Type["NotificationChannel"], force: bool = False) -> None: + """Register a notification channel class""" + from .channels import NotificationChannel + + self._register(channel_class, NotificationChannel, self._channel_classes, "NotificationChannel", force) + + def register_frequency(self, frequency_class: Type["NotificationFrequency"], force: bool = False) -> None: + """Register an email frequency option class""" + from .frequencies import NotificationFrequency + + self._register(frequency_class, NotificationFrequency, self._frequency_classes, "NotificationFrequency", force) + + def get_type(self, key: str) -> "NotificationType": + """Get a registered notification type instance by key""" + return self._type_classes[key]() + + def get_channel(self, key: str) -> "NotificationChannel": + """Get a registered channel instance by key""" + return self._channel_classes[key]() + + def get_frequency(self, key: str) -> "NotificationFrequency": + """Get a registered frequency instance by key""" + return self._frequency_classes[key]() + + def get_all_types(self) -> list["NotificationType"]: + """Get all registered notification type instances""" + return [cls() for cls in self._type_classes.values()] + + def get_all_channels(self) -> list["NotificationChannel"]: + """Get all registered channel instances""" + return [cls() for cls in self._channel_classes.values()] + + def get_all_frequencies(self) -> list["NotificationFrequency"]: + """Get all registered frequency instances""" + return [cls() for cls in self._frequency_classes.values()] + + def get_realtime_frequencies(self) -> list["NotificationFrequency"]: + """Get all frequencies marked as realtime""" + return [cls() for cls in self._frequency_classes.values() if cls.is_realtime] + + def unregister_type(self, type_class: Type["NotificationType"]) -> bool: + """ + Unregister a notification type by class. + + Args: + type_class: The notification type class to remove + + Returns: + bool: True if a type was removed, False if key didn't exist + """ + return self._type_classes.pop(type_class.key, None) is not None + + def unregister_channel(self, channel_class: Type["NotificationChannel"]) -> bool: + """ + Unregister a channel by class. + + Args: + channel_class: The channel class to remove + + Returns: + bool: True if a channel was removed, False if key didn't exist + """ + return self._channel_classes.pop(channel_class.key, None) is not None + + def unregister_frequency(self, frequency_class: Type["NotificationFrequency"]) -> bool: + """ + Unregister a frequency by class. + + Args: + frequency_class: The frequency class to remove + + Returns: + bool: True if a frequency was removed, False if key didn't exist + """ + return self._frequency_classes.pop(frequency_class.key, None) is not None + + def clear_types(self) -> None: + """Remove all registered notification types.""" + self._type_classes.clear() + + def clear_channels(self) -> None: + """Remove all registered channels.""" + self._channel_classes.clear() + + def clear_frequencies(self) -> None: + """Remove all registered frequencies.""" + self._frequency_classes.clear() + + +# Global registry instance +registry = NotificationRegistry() diff --git a/generic_notifications/types.py b/generic_notifications/types.py new file mode 100644 index 0000000..de48bbe --- /dev/null +++ b/generic_notifications/types.py @@ -0,0 +1,93 @@ +from abc import ABC +from typing import TYPE_CHECKING, Type + +from .channels import EmailChannel, NotificationChannel +from .frequencies import DailyFrequency, NotificationFrequency, RealtimeFrequency +from .registry import registry + +if TYPE_CHECKING: + from .models import Notification + + +class NotificationType(ABC): + """ + Represents a type of notification that can be sent to users. + """ + + key: str + name: str + description: str + default_email_frequency: Type[NotificationFrequency] = DailyFrequency + required_channels: list[Type[NotificationChannel]] = [] + + def __str__(self) -> str: + return self.name + + @classmethod + def should_save(cls, notification: "Notification") -> bool: + """ + A hook to prevent the saving of a new notification. You can use + this hook to find similar (unread) notifications and then instead + of creating this new notification, update the existing notification + with a `count` property (stored in the `metadata` field). + The `get_subject` or `get_text` methods can then use this `count` + to dynamically change the text from "you received a comment" to + "you received two comments", for example. + """ + return True + + def get_subject(self, notification: "Notification") -> str: + """ + Generate dynamic subject based on notification data. + Override this in subclasses for custom behavior. + """ + return "" + + def get_text(self, notification: "Notification") -> str: + """ + Generate dynamic text based on notification data. + Override this in subclasses for custom behavior. + """ + return "" + + +def register(cls: Type[NotificationType]) -> Type[NotificationType]: + """ + Decorator that registers a NotificationType subclass. + + Usage: + @register + class CommentNotificationType(NotificationType): + key = "comment_notification" + name = "Comments" + description = "You received a comment" + + def get_subject(self, notification): + return f"{notification.actor.name} commented on your article" + """ + # Register the class + registry.register_type(cls) + + # Return the class unchanged + return cls + + +@register +class SystemMessage(NotificationType): + key = "system_message" + name = "System Message" + description = "Important system notifications" + default_email_frequency = RealtimeFrequency + required_channels = [EmailChannel] + + def get_subject(self, notification: "Notification") -> str: + """Generate subject for system messages.""" + if notification.subject: + return notification.subject + return f"System Message: {self.name}" + + def get_text(self, notification: "Notification") -> str: + """Generate text for system messages.""" + if notification.text: + return notification.text + return self.description or f"You have a new {self.name.lower()} notification" diff --git a/generic_notifications/utils.py b/generic_notifications/utils.py new file mode 100644 index 0000000..e56f37d --- /dev/null +++ b/generic_notifications/utils.py @@ -0,0 +1,63 @@ +from typing import Any + +from django.db.models import QuerySet +from django.utils import timezone + +from .channels import NotificationChannel, WebsiteChannel + + +def mark_notifications_as_read(user: Any, notification_ids: list[int] | None = None) -> None: + """ + Mark notifications as read for a user. + + Args: + user: User instance + notification_ids: List of notification IDs to mark as read. + If None, marks all unread notifications as read. + """ + queryset = user.notifications.filter(read__isnull=True) + + if notification_ids: + queryset = queryset.filter(id__in=notification_ids) + + queryset.update(read=timezone.now()) + + +def get_unread_count(user: Any, channel: type[NotificationChannel] = WebsiteChannel) -> int: + """ + Get count of unread notifications for a user, filtered by channel. + + Args: + user: User instance + channel: Channel to filter by (e.g., WebsiteChannel, EmailChannel) + + Returns: + Count of unread notifications for the specified channel + """ + return user.notifications.filter(read__isnull=True, channels__icontains=f'"{channel.key}"').count() + + +def get_notifications( + user: Any, channel: type[NotificationChannel] = WebsiteChannel, unread_only: bool = False, limit: int | None = None +) -> QuerySet: + """ + Get notifications for a user, filtered by channel. + + Args: + user: User instance + channel: Channel to filter by (e.g., WebsiteChannel, EmailChannel) + unread_only: If True, only return unread notifications + limit: Maximum number of notifications to return + + Returns: + QuerySet of Notification objects + """ + queryset = user.notifications.prefetch().for_channel(channel) + + if unread_only: + queryset = queryset.unread() + + if limit: + queryset = queryset[:limit] + + return queryset diff --git a/notifications/__init__.py b/notifications/__init__.py deleted file mode 100644 index 020ed73..0000000 --- a/notifications/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '0.2.2' diff --git a/notifications/backend/__init__.py b/notifications/backend/__init__.py deleted file mode 100644 index a11ef1e..0000000 --- a/notifications/backend/__init__.py +++ /dev/null @@ -1,74 +0,0 @@ -from notifications.engine import NotificationEngine - - -class BackendConfigError(Exception): - pass - - -class BaseNotificationBackend(object): - process_method = None - name = None - - @classmethod - def get_name(cls): - return cls.name or cls.__name__ - - def __init__(self, user=None, subject=None, text=None, send_at=None): - if self.process_method not in ['queue', 'direct']: - raise BackendConfigError('You need to set the process_method property of %s to either "queue" or "direct"' % self.__class__.__name__) - - self.user = user - self.subject = subject - self.text = text - self.send_at = send_at - - def is_registered(self): - return self.__class__.__name__ in NotificationEngine._backends - - def _queue(self): - """ - This will add the notification to the database queue. A cron job will then get the notification, - and call the process() method of its backend. - """ - from notifications.models import NotificationQueue - - NotificationQueue.objects.create( - user=self.user, - subject=self.subject, - text=self.text, - send_at=self.send_at, - notification_backend=self.__class__.__name__ - ) - - def _direct(self): - """ - This will show the notification directly. This is a blocking operation, so should not be - used for things that take time, like sending email, sms or iPhone push notifications. - It's useful for Django's messages app, popups, etc. - """ - return self.process() - - def validate(self): - """ - Check for required settings. For example, if your backend needs a phone number, check for it here. - """ - return True - - def create(self): - """ - Will figure out if this notification should go to the queue, - or if it should be shown directly. - """ - if self.process_method == 'queue': - return self._queue() - - if self.process_method == 'direct': - return self._direct() - - def process(self): - """ - This will do the actual processing of the notification. - Each backend should do it own thing here! - Should return a boolean, stating if processing the notification was successful. - """ - raise NotImplementedError('The process method needs to be overridden per notification backend') diff --git a/notifications/backend/django_email.py b/notifications/backend/django_email.py deleted file mode 100644 index 18ff8b5..0000000 --- a/notifications/backend/django_email.py +++ /dev/null @@ -1,59 +0,0 @@ -from django.conf import settings -from django.core.mail import send_mail -from notifications.backend import BaseNotificationBackend -from notifications import settings as notification_settings - - -class MissingEmailError(Exception): - pass - - -class DjangoEmailNotificationBackend(BaseNotificationBackend): - """ - A backend that sends email using Django's standard send_mail function. - """ - process_method = 'queue' - name = 'Email' - - def _validate_list(self, lst): - """ - Make sure we end up with a list - """ - if not lst: - return [] - if isinstance(lst, basestring): - return [lst] - return list(lst) - - def validate(self): - """ - This backend can only function when there are to and from addresses. - The to address can either be supplied to the notification type, or the logged in user should be supplied. - The from address can either be supplied, or should be set as settings.DEFAULT_FROM_EMAIL - """ - - self.to = self.user.email - self.from_address = settings.DEFAULT_FROM_EMAIL - - if not self.to: - if notification_settings.FAIL_SILENT: - return False - raise MissingEmailError('EmailNotificationBackend needs an email address.') - - if not self.from_address: - if notification_settings.FAIL_SILENT: - return False - raise MissingEmailError('EmailNotificationBackend needs a from address.') - - return True - - def process(self): - """ - Send email using Django's standard email function - """ - return send_mail( - self.subject, - self.text, - self.from_address, - self._validate_list(self.to) - ) diff --git a/notifications/backend/generic_email.py b/notifications/backend/generic_email.py deleted file mode 100644 index 7033cb7..0000000 --- a/notifications/backend/generic_email.py +++ /dev/null @@ -1,28 +0,0 @@ -from notifications.backend.django_email import DjangoEmailNotificationBackend -from generic_mail import Email - - -class GenericEmailNotificationBackend(DjangoEmailNotificationBackend): - """ - A backend that sends email using https://github.com/kevinrenskers/django-generic-mail. - It's not registered by default, you will need to do this yourself. - """ - name = 'Email2' - - def process(self): - text_body = self.text - html_body = None - text_template = None - html_template = None - - email = Email( - to=self.to, - subject=self.subject, - text_body=text_body, - html_body=html_body, - text_template=text_template, - html_template=html_template, - from_address=self.from_address - ) - - return email.send() diff --git a/notifications/engine.py b/notifications/engine.py deleted file mode 100644 index 2f45e58..0000000 --- a/notifications/engine.py +++ /dev/null @@ -1,47 +0,0 @@ -class AlreadyRegistered(Exception): - pass - - -class NotRegistered(Exception): - pass - - -class NotificationEngine(object): - _types = {} - _backends = {} - - @classmethod - def register_backend(cls, backend): - key = backend.__name__ - - if key in cls._backends: - raise AlreadyRegistered('The notification backend %s is already registered' % key) - - cls._backends[key] = backend - - @classmethod - def unregister_backend(cls, backend): - key = backend.__name__ - - if key not in cls._backends: - raise NotRegistered('The notification backend %s is not registered' % key) - - del cls._backends[key] - - @classmethod - def register_type(cls, type): - key = type.__name__ - - if key in cls._types: - raise AlreadyRegistered('The notification type %s is already registered' % key) - - cls._types[key] = type - - @classmethod - def unregister_type(cls, type): - key = type.__name__ - - if key not in cls._types: - raise NotRegistered('The notification type %s is not registered' % key) - - del cls._types[key] diff --git a/notifications/fields.py b/notifications/fields.py deleted file mode 100644 index 11ce5f4..0000000 --- a/notifications/fields.py +++ /dev/null @@ -1,90 +0,0 @@ -import datetime -from decimal import Decimal -from django.db import models -from django.conf import settings -from django.utils import simplejson - - -class JSONEncoder(simplejson.JSONEncoder): - def default(self, obj): - if isinstance(obj, Decimal): - return str(obj) - elif isinstance(obj, datetime.datetime): - assert settings.TIME_ZONE == 'UTC' - return obj.strftime('%Y-%m-%dT%H:%M:%SZ') - return simplejson.JSONEncoder.default(self, obj) - - -def dumps(value): - assert isinstance(value, dict) - return JSONEncoder().encode(value) - - -def loads(txt): - value = simplejson.loads( - txt, - parse_float=Decimal, - encoding=settings.DEFAULT_CHARSET - ) - assert isinstance(value, dict) - return value - - -class JSONDict(dict): - """ - Hack so repr() called by dumpdata will output JSON instead of - Python formatted data. This way fixtures will work! - """ - def __repr__(self): - return dumps(self) - - -class JSONField(models.TextField): - """JSONField is a generic textfield that neatly serializes/unserializes - JSON objects seamlessly. Main thingy must be a dict object.""" - - # Used so to_python() is called - __metaclass__ = models.SubfieldBase - - def __init__(self, *args, **kwargs): - if 'default' not in kwargs: - kwargs['default'] = '{}' - models.TextField.__init__(self, *args, **kwargs) - - def to_python(self, value): - """Convert our string value to JSON after we load it from the DB""" - if not value: - return {} - elif isinstance(value, basestring): - res = loads(value) - assert isinstance(res, dict) - return JSONDict(**res) - else: - return value - - def get_db_prep_save(self, value, connection): - """Convert our JSON object to a string before we save""" - if not value: - return super(JSONField, self).get_db_prep_save("") - else: - return super(JSONField, self).get_db_prep_save(dumps(value)) - - def south_field_triple(self): - "Returns a suitable description of this field for South." - # We'll just introspect the _actual_ field. - from south.modelsinspector import introspector - field_class = "django.db.models.fields.TextField" - args, kwargs = introspector(self) - # That's our definition! - return (field_class, args, kwargs) - - - -# -# Convince South to to migrate fields of this type -# -try: - from south.modelsinspector import add_introspection_rules - add_introspection_rules([], ["^notifications\.fields\.JSONField"]) -except: - pass diff --git a/notifications/management/__init__.py b/notifications/management/__init__.py deleted file mode 100644 index a9a72cd..0000000 --- a/notifications/management/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__author__ = 'kevin' diff --git a/notifications/management/commands/__init__.py b/notifications/management/commands/__init__.py deleted file mode 100644 index a9a72cd..0000000 --- a/notifications/management/commands/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__author__ = 'kevin' diff --git a/notifications/management/commands/process_notifications.py b/notifications/management/commands/process_notifications.py deleted file mode 100644 index 6ae8bbc..0000000 --- a/notifications/management/commands/process_notifications.py +++ /dev/null @@ -1,35 +0,0 @@ -from datetime import datetime - -from django.core.management.base import NoArgsCommand -from django.db.models import F - -from notifications.models import NotificationQueue -from notifications import settings as notification_settings - - -class Command(NoArgsCommand): - def handle_noargs(self, **options): - notifications = NotificationQueue.objects.exclude(send_at__gte=datetime.now()) - - for notification in notifications: - if notification.tries >= notification_settings.QUEUE_MAX_TRIES: - # Don't try it any more - # TODO: logging, email error, etc - notification.delete() - continue - - backend_class = notification.get_backend() - backend = backend_class(user=notification.user, subject=notification.subject, text=notification.text) - - result = backend.validate() - if result: - result = backend.process() - - if result: - # success, simply delete the notification from the queue - notification.delete() - else: - # error, update the try counter - # TODO: logging, email error, etc - notification.tries = F('tries') + 1 - notification.save() diff --git a/notifications/migrations/0001_initial.py b/notifications/migrations/0001_initial.py deleted file mode 100644 index 6a558e6..0000000 --- a/notifications/migrations/0001_initial.py +++ /dev/null @@ -1,127 +0,0 @@ -# encoding: utf-8 -import datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - -class Migration(SchemaMigration): - - def forwards(self, orm): - - # Adding model 'NotificationQueue' - db.create_table('notifications_notificationqueue', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='notifications', to=orm['auth.User'])), - ('subject', self.gf('django.db.models.fields.CharField')(max_length=255, blank=True)), - ('text', self.gf('django.db.models.fields.TextField')(blank=True)), - ('tries', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)), - ('notification_backend', self.gf('django.db.models.fields.CharField')(max_length=255)), - )) - db.send_create_signal('notifications', ['NotificationQueue']) - - # Adding model 'SelectedNotificationsType' - db.create_table('notifications_selectednotificationstype', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), - ('notification_type', self.gf('django.db.models.fields.CharField')(max_length=255)), - ('notification_backends', self.gf('django.db.models.fields.TextField')()), - )) - db.send_create_signal('notifications', ['SelectedNotificationsType']) - - # Adding unique constraint on 'SelectedNotificationsType', fields ['user', 'notification_type'] - db.create_unique('notifications_selectednotificationstype', ['user_id', 'notification_type']) - - # Adding model 'NotificationBackendSettings' - db.create_table('notifications_notificationbackendsettings', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), - ('notification_backend', self.gf('django.db.models.fields.CharField')(max_length=255)), - ('settings', self.gf('django.db.models.fields.TextField')(default='{}')), - )) - db.send_create_signal('notifications', ['NotificationBackendSettings']) - - # Adding unique constraint on 'NotificationBackendSettings', fields ['user', 'notification_backend'] - db.create_unique('notifications_notificationbackendsettings', ['user_id', 'notification_backend']) - - - def backwards(self, orm): - - # Removing unique constraint on 'NotificationBackendSettings', fields ['user', 'notification_backend'] - db.delete_unique('notifications_notificationbackendsettings', ['user_id', 'notification_backend']) - - # Removing unique constraint on 'SelectedNotificationsType', fields ['user', 'notification_type'] - db.delete_unique('notifications_selectednotificationstype', ['user_id', 'notification_type']) - - # Deleting model 'NotificationQueue' - db.delete_table('notifications_notificationqueue') - - # Deleting model 'SelectedNotificationsType' - db.delete_table('notifications_selectednotificationstype') - - # Deleting model 'NotificationBackendSettings' - db.delete_table('notifications_notificationbackendsettings') - - - models = { - 'auth.group': { - 'Meta': {'object_name': 'Group'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - 'auth.permission': { - 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - 'auth.user': { - 'Meta': {'object_name': 'User'}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'unique': 'True', 'null': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) - }, - 'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - 'notifications.notificationbackendsettings': { - 'Meta': {'unique_together': "(['user', 'notification_backend'],)", 'object_name': 'NotificationBackendSettings'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'notification_backend': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'settings': ('django.db.models.fields.TextField', [], {'default': "'{}'"}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'notifications.notificationqueue': { - 'Meta': {'object_name': 'NotificationQueue'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'notification_backend': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'subject': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), - 'text': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'tries': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'notifications'", 'to': "orm['auth.User']"}) - }, - 'notifications.selectednotificationstype': { - 'Meta': {'unique_together': "(['user', 'notification_type'],)", 'object_name': 'SelectedNotificationsType'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'notification_backends': ('django.db.models.fields.TextField', [], {}), - 'notification_type': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - } - } - - complete_apps = ['notifications'] diff --git a/notifications/migrations/0002_auto__del_selectednotificationstype__del_unique_selectednotificationst.py b/notifications/migrations/0002_auto__del_selectednotificationstype__del_unique_selectednotificationst.py deleted file mode 100644 index 3c42c6d..0000000 --- a/notifications/migrations/0002_auto__del_selectednotificationstype__del_unique_selectednotificationst.py +++ /dev/null @@ -1,113 +0,0 @@ -# encoding: utf-8 -import datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - -class Migration(SchemaMigration): - - def forwards(self, orm): - - # Removing unique constraint on 'SelectedNotificationsType', fields ['user', 'notification_type'] - db.delete_unique('notifications_selectednotificationstype', ['user_id', 'notification_type']) - - # Deleting model 'SelectedNotificationsType' - db.delete_table('notifications_selectednotificationstype') - - # Adding model 'DisabledNotificationsTypeBackend' - db.create_table('notifications_disablednotificationstypebackend', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), - ('notification_type', self.gf('django.db.models.fields.CharField')(max_length=255)), - ('notification_backends', self.gf('django.db.models.fields.TextField')()), - )) - db.send_create_signal('notifications', ['DisabledNotificationsTypeBackend']) - - # Adding unique constraint on 'DisabledNotificationsTypeBackend', fields ['user', 'notification_type'] - db.create_unique('notifications_disablednotificationstypebackend', ['user_id', 'notification_type']) - - - def backwards(self, orm): - - # Removing unique constraint on 'DisabledNotificationsTypeBackend', fields ['user', 'notification_type'] - db.delete_unique('notifications_disablednotificationstypebackend', ['user_id', 'notification_type']) - - # Adding model 'SelectedNotificationsType' - db.create_table('notifications_selectednotificationstype', ( - ('notification_type', self.gf('django.db.models.fields.CharField')(max_length=255)), - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('notification_backends', self.gf('django.db.models.fields.TextField')()), - ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), - )) - db.send_create_signal('notifications', ['SelectedNotificationsType']) - - # Adding unique constraint on 'SelectedNotificationsType', fields ['user', 'notification_type'] - db.create_unique('notifications_selectednotificationstype', ['user_id', 'notification_type']) - - # Deleting model 'DisabledNotificationsTypeBackend' - db.delete_table('notifications_disablednotificationstypebackend') - - - models = { - 'auth.group': { - 'Meta': {'object_name': 'Group'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - 'auth.permission': { - 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - 'auth.user': { - 'Meta': {'object_name': 'User'}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'unique': 'True', 'null': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) - }, - 'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - 'notifications.disablednotificationstypebackend': { - 'Meta': {'unique_together': "(['user', 'notification_type'],)", 'object_name': 'DisabledNotificationsTypeBackend'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'notification_backends': ('django.db.models.fields.TextField', [], {}), - 'notification_type': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'notifications.notificationbackendsettings': { - 'Meta': {'unique_together': "(['user', 'notification_backend'],)", 'object_name': 'NotificationBackendSettings'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'notification_backend': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'settings': ('django.db.models.fields.TextField', [], {'default': "'{}'"}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'notifications.notificationqueue': { - 'Meta': {'object_name': 'NotificationQueue'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'notification_backend': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'subject': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), - 'text': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'tries': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'notifications'", 'to': "orm['auth.User']"}) - } - } - - complete_apps = ['notifications'] diff --git a/notifications/migrations/0003_auto__add_field_notificationqueue_send_at.py b/notifications/migrations/0003_auto__add_field_notificationqueue_send_at.py deleted file mode 100644 index c5ccad5..0000000 --- a/notifications/migrations/0003_auto__add_field_notificationqueue_send_at.py +++ /dev/null @@ -1,84 +0,0 @@ -# encoding: utf-8 -import datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - -class Migration(SchemaMigration): - - def forwards(self, orm): - - # Adding field 'NotificationQueue.send_at' - db.add_column('notifications_notificationqueue', 'send_at', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True), keep_default=False) - - - def backwards(self, orm): - - # Deleting field 'NotificationQueue.send_at' - db.delete_column('notifications_notificationqueue', 'send_at') - - - models = { - 'auth.group': { - 'Meta': {'object_name': 'Group'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - 'auth.permission': { - 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - 'auth.user': { - 'Meta': {'object_name': 'User'}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '75'}) - }, - 'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - 'notifications.disablednotificationstypebackend': { - 'Meta': {'unique_together': "(['user', 'notification_type'],)", 'object_name': 'DisabledNotificationsTypeBackend'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'notification_backends': ('django.db.models.fields.TextField', [], {}), - 'notification_type': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'notifications.notificationbackendsettings': { - 'Meta': {'unique_together': "(['user', 'notification_backend'],)", 'object_name': 'NotificationBackendSettings'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'notification_backend': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'settings': ('django.db.models.fields.TextField', [], {'default': "'{}'"}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'notifications.notificationqueue': { - 'Meta': {'object_name': 'NotificationQueue'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'notification_backend': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'send_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), - 'subject': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), - 'text': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'tries': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'notifications'", 'to': "orm['auth.User']"}) - } - } - - complete_apps = ['notifications'] diff --git a/notifications/models.py b/notifications/models.py deleted file mode 100644 index 76a9d7c..0000000 --- a/notifications/models.py +++ /dev/null @@ -1,56 +0,0 @@ -from datetime import datetime - -from django.contrib.auth.models import User -from django.db import models -from notifications.backend.django_email import DjangoEmailNotificationBackend -from notifications.engine import NotificationEngine - -from notifications.fields import JSONField -from notifications.type.account import AccountNotification -from notifications.type.default import DefaultNotification - - -class NotificationQueue(models.Model): - """ - The queue is used for backends that have show_method set to "queue" - """ - user = models.ForeignKey(User, related_name='notifications') - subject = models.CharField(max_length=255, blank=True) - text = models.TextField(blank=True) - tries = models.PositiveIntegerField(default=0) - send_at = models.DateTimeField(blank=True, null=True) - notification_backend = models.CharField(max_length=255) - - def get_backend(self): - return NotificationEngine._backends[self.notification_backend] - - def __unicode__(self): - return self.text - - -class DisabledNotificationsTypeBackend(models.Model): - """ - In this model we save which backends a user does NOT want to use for a notification type. - By saving what he does NOT want, new users and new types/backends default to everything. - """ - user = models.ForeignKey(User) - notification_type = models.CharField(max_length=255) - notification_backends = models.TextField() - - def get_backends(self): - return self.notification_backends.split(',') - - class Meta: - unique_together = ['user', 'notification_type'] - - -class NotificationBackendSettings(models.Model): - """ - Model for saving required settings per backend - """ - user = models.ForeignKey(User) - notification_backend = models.CharField(max_length=255) - settings = JSONField() - - class Meta: - unique_together = ['user', 'notification_backend'] diff --git a/notifications/settings.py b/notifications/settings.py deleted file mode 100644 index 91f2da1..0000000 --- a/notifications/settings.py +++ /dev/null @@ -1,4 +0,0 @@ -from django.conf import settings - -QUEUE_MAX_TRIES = getattr(settings, 'NOTIFICATION_QUEUE_MAX_TRIES', 5) -FAIL_SILENT = getattr(settings, 'NOTIFICATION_FAIL_SILENT', True) diff --git a/notifications/templates/notifications/index.html b/notifications/templates/notifications/index.html deleted file mode 100644 index f56e5d9..0000000 --- a/notifications/templates/notifications/index.html +++ /dev/null @@ -1,41 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
- {% csrf_token %} - - - - - - {% for backend_class_name, backend_class in notification_backends.items %} - - {% endfor %} - - - - - {% for type_class_name, type_dict in notification_types.items %} - - - - {% for backend_class_name, backend_class in notification_backends.items %} - - {% endfor %} - - {% endfor %} - -
Type{{ backend_class.get_name }}
- {{ type_dict.type_class.get_name }}
- {{ type_dict.type_class.get_help }} -
- {% if backend_class_name in type_dict.type_class.allowed_backends %} - - {% endif %} -
- -
- -
-
-{% endblock %} diff --git a/notifications/type/__init__.py b/notifications/type/__init__.py deleted file mode 100644 index 770a88f..0000000 --- a/notifications/type/__init__.py +++ /dev/null @@ -1,121 +0,0 @@ -from notifications.engine import NotificationEngine -from notifications import settings as notification_settings - - -class BackendError(Exception): - pass - -class NotificationTypeError(Exception): - pass - -class BaseNotification(object): - name = None - help = '' - - subject = None - text = None - send_at = None - allowed_backends = [] - - _all_backends = NotificationEngine._backends - - @classmethod - def get_name(cls): - return cls.name or cls.__name__ - - @classmethod - def get_help(cls): - return cls.help - - def __init__(self, subject=None, text=None, request=None, user=None, **kwargs): - # By default all registered backends are allowed - if not self.allowed_backends: - self.allowed_backends = NotificationEngine._backends.keys() - - self.subject = subject or self.subject - self.text = text or self.text - self.user = user - self.request = request - self.kwargs = kwargs - - def is_registered(self): - return self.__class__.__name__ in NotificationEngine._types - - def send(self): - # Is this type registered? If not, we can't do anything - if not self.is_registered(): - if notification_settings.FAIL_SILENT: - return False - raise NotificationTypeError('No recipients found for this notification') - - users = self.get_recipients() - - # Force users to be a list - if not hasattr(users, '__iter__'): - users = [users] - - if not users and not notification_settings.FAIL_SILENT: - raise NotificationTypeError('No recipients found for this notification') - - for user in users: - backends = self._get_backends(user) - for backend_name, backend in backends.items(): - # the backend will figure out if it needs to queue or not - backend.create() - - def get_recipients(self): - user = self.user - - if not user: - try: - user = self.request.user - except KeyError: - if notification_settings.FAIL_SILENT: - return False - raise BackendError('No user or request object given. Please give at least one of them, or override get_recipients') - - return user - - def get_text(self, backend, user): - return self.text - - def get_subject(self, backend, user): - return self.subject - - def get_send_at(self, backend, user): - return self.send_at - - def _get_backends(self, user): - """ - Get the correct backend(s) for this notification. - Only backends that validate (all required settings are available) apply. - """ - - from notifications.models import DisabledNotificationsTypeBackend - - disabled_backends = [] - - try: - disabled_backends = DisabledNotificationsTypeBackend.objects.get(user=user, notification_type=self.__class__.__name__).get_backends() - except DisabledNotificationsTypeBackend.DoesNotExist: - pass - - backends = {} - for backend_name in self.allowed_backends: - if backend_name not in disabled_backends: - subject = self.get_subject(backend_name, user) - text = self.get_text(backend_name, user) - send_at = self.get_send_at(backend_name, user) - backend = self._all_backends[backend_name](user=user, subject=subject, text=text, send_at=send_at) - if backend.is_registered() and backend.validate(): - backends[backend_name] = backend - - if not backends and not notification_settings.FAIL_SILENT: - raise BackendError('Could not find a backend for this notification') - - return backends - - def __getattr__(self, name): - # Makes it much easier to access the keyword arguments - # http://docs.python.org/reference/datamodel.html#customizing-attribute-access - return self.kwargs[name] diff --git a/notifications/type/account.py b/notifications/type/account.py deleted file mode 100644 index e61dc11..0000000 --- a/notifications/type/account.py +++ /dev/null @@ -1,8 +0,0 @@ -from notifications.type import BaseNotification - - -class AccountNotification(BaseNotification): - """ - Notifications that deal with your account will only go to the EmailNotificationBackend - """ - allowed_backends = ['DjangoEmailNotificationBackend'] diff --git a/notifications/type/default.py b/notifications/type/default.py deleted file mode 100644 index 3d6e85e..0000000 --- a/notifications/type/default.py +++ /dev/null @@ -1,10 +0,0 @@ -from notifications.type import BaseNotification - - -class DefaultNotification(BaseNotification): - """ - The default notification type that can be used for all kinds of things. - All possible backends are allowed. - """ - - pass diff --git a/notifications/urls.py b/notifications/urls.py deleted file mode 100644 index 9923bdc..0000000 --- a/notifications/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.conf.urls.defaults import patterns, url -from django.contrib.auth.decorators import login_required -from notifications.views import IndexView - - -urlpatterns = patterns('', - url(r'^$', login_required(IndexView.as_view()), name='notification-settings-index'), -) diff --git a/notifications/views.py b/notifications/views.py deleted file mode 100644 index cd92bfb..0000000 --- a/notifications/views.py +++ /dev/null @@ -1,49 +0,0 @@ -from django.contrib import messages -from django.core.urlresolvers import reverse -from django.http import HttpResponseRedirect -from django.views.generic.base import TemplateView -from notifications.engine import NotificationEngine -from notifications.models import DisabledNotificationsTypeBackend - - -class IndexView(TemplateView): - template_name = 'notifications/index.html' - - def get_context_data(self, **kwargs): - disabled_backends = {} - - for obj in DisabledNotificationsTypeBackend.objects.filter(user=self.request.user): - disabled_backends[obj.notification_type] = obj.get_backends() - - notification_types = {} - for class_name, type_class in NotificationEngine._types.items(): - notification_types[class_name] = { - 'type_class': type_class, - 'disabled_backends': disabled_backends.get(class_name, []) - } - - context = super(IndexView, self).get_context_data(**kwargs) - context['notification_types'] = notification_types - context['notification_backends'] = NotificationEngine._backends - return context - - def post(self, request, **kwargs): - DisabledNotificationsTypeBackend.objects.filter(user=request.user).delete() - - for type_name, type_class in NotificationEngine._types.items(): - allowed_backends = type_class().allowed_backends - enabled_backends = request.POST.getlist(type_name) - - # Subtract the enabled backends from allowed_backends, the difference is disabled_backends - disabled_backends = list(allowed_backends) - [disabled_backends.remove(x) for x in enabled_backends] - - if disabled_backends: - DisabledNotificationsTypeBackend.objects.create( - user = request.user, - notification_type = type_name, - notification_backends = ','.join(disabled_backends) - ) - - messages.success(request, 'Your notification settings have been saved') - return HttpResponseRedirect(reverse('notification-settings-index')) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8012c00 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,59 @@ +[project] +name = "django-generic-notifications" +version = "1.0.0" +description = "A flexible, multi-channel notification system for Django applications with built-in support for email digests, user preferences, and extensible delivery channels." +authors = [ + {name = "Kevin Renskers", email = "kevin@loopwerk.io"}, +] +license = "MIT" +license-files = [ "LICENSE" ] +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "django>=3.2.0", +] +keywords = [ + "django", + "notifications", + "email", + "digest", + "multi-channel", +] + +[dependency-groups] +dev = [ + "mypy>=1.15.0", + "pytest>=8.3.5", + "pytest-django>=4.10.0", + "ruff>=0.11.2", +] + +[project.urls] +Homepage = "https://github.com/loopwerk/django-generic-notifications/" +Repository = "https://github.com/loopwerk/django-generic-notifications.git" +Issues = "https://github.com/loopwerk/django-generic-notifications/issues" + +[build-system] +requires = ["uv_build>=0.7.19,<0.8.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "generic_notifications" +module-root = "" + +[tool.uv] +package = true + +[tool.ruff] +line-length = 120 +lint.extend-select = ["I", "N"] + +[tool.mypy] +disable_error_code = ["import-untyped"] +warn_redundant_casts = true +check_untyped_defs = true + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "tests.settings" +python_files = "test*.py" +filterwarnings = ["ignore::DeprecationWarning"] diff --git a/setup.py b/setup.py deleted file mode 100644 index 8a9d701..0000000 --- a/setup.py +++ /dev/null @@ -1,32 +0,0 @@ -from distutils.core import setup -from notifications import __version__ as version - -setup( - name="django-generic-notifications", - version=version, - description="Generic notification system for Django, with multiple input types and output backends", - long_description=open("README.rst").read(), - author="Kevin Renskers", - author_email="info@mixedcase.nl", - url="https://github.com/kevinrenskers/django-generic-notifications", - packages=[ - "notifications", - ], - install_requires=[ - "django >= 1.2", - ], - package_dir={"notifications": "notifications"}, - classifiers=[ - "Development Status :: 4 - Beta", - "Environment :: Web Environment", - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.5', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - "Framework :: Django", - "Topic :: Software Development :: Libraries :: Python Modules", - ] -) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..cbc4240 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,18 @@ +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "generic_notifications", +] + +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.MD5PasswordHasher", +] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } +} + +SECRET_KEY = "test_secret_key" diff --git a/tests/test_channels.py b/tests/test_channels.py new file mode 100644 index 0000000..a856fe7 --- /dev/null +++ b/tests/test_channels.py @@ -0,0 +1,377 @@ +from typing import Any +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.core import mail +from django.test import TestCase, override_settings + +from generic_notifications.channels import EmailChannel, NotificationChannel +from generic_notifications.frequencies import DailyFrequency, RealtimeFrequency +from generic_notifications.models import DisabledNotificationTypeChannel, EmailFrequency, Notification +from generic_notifications.registry import registry +from generic_notifications.types import NotificationType + +User = get_user_model() + + +# Test subclasses for abstract base classes +class TestNotificationType(NotificationType): + key = "test_type" + name = "Test Type" + description = "A test notification type" + default_email_frequency = RealtimeFrequency + + +class NotificationChannelTest(TestCase): + user: Any # User model instance created in setUpClass + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = User.objects.create_user(username="user1", email="test@example.com", password="testpass") + + def test_notification_channel_is_abstract(self): + class TestChannel(NotificationChannel): + key = "test" + name = "Test" + + def process(self, notification): + pass + + channel = TestChannel() + self.assertEqual(channel.key, "test") + self.assertEqual(channel.name, "Test") + + def test_is_enabled_default_true(self): + class TestChannel(NotificationChannel): + key = "test" + name = "Test" + + def process(self, notification): + pass + + channel = TestChannel() + # By default, all notifications are enabled + self.assertTrue(channel.is_enabled(self.user, "any_type")) + + def test_is_enabled_with_disabled_notification(self): + class TestChannel(NotificationChannel): + key = "test" + name = "Test" + + def process(self, notification): + pass + + channel = TestChannel() + + # Disable notification channel for this user + DisabledNotificationTypeChannel.objects.create( + user=self.user, notification_type="disabled_type", channel="test" + ) + + # Should be disabled for this type + self.assertFalse(channel.is_enabled(self.user, "disabled_type")) + + # But enabled for other types + self.assertTrue(channel.is_enabled(self.user, "other_type")) + + +class WebsiteChannelTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="user2", email="test@example.com", password="testpass") + registry.register_type(TestNotificationType) + + self.notification = Notification.objects.create( + recipient=self.user, notification_type="test_type", subject="Test Subject" + ) + + def tearDown(self): + pass + + +class EmailChannelTest(TestCase): + user: Any # User model instance created in setUp + + def setUp(self): + self.user = User.objects.create_user(username="user1", email="test@example.com", password="testpass") + registry.register_type(TestNotificationType) + + def tearDown(self): + mail.outbox.clear() + + def test_get_frequency_with_user_preference(self): + EmailFrequency.objects.create(user=self.user, notification_type="test_type", frequency="daily") + + channel = EmailChannel() + frequency = channel.get_frequency(self.user, "test_type") + + self.assertEqual(frequency.key, "daily") + + def test_get_frequency_default_realtime(self): + channel = EmailChannel() + frequency = channel.get_frequency(self.user, "test_type") + + # Should default to first realtime frequency + self.assertEqual(frequency.key, "realtime") + + def test_get_frequency_fallback_when_no_realtime(self): + # Clear realtime frequencies and add only non-realtime + registry.unregister_frequency(RealtimeFrequency) + registry.register_frequency(DailyFrequency) + + channel = EmailChannel() + frequency = channel.get_frequency(self.user, "test_type") + + # Should fallback to "realtime" string + self.assertEqual(frequency.key, "realtime") + + @override_settings(DEFAULT_FROM_EMAIL="test@example.com") + def test_process_realtime_frequency(self): + notification = Notification.objects.create( + recipient=self.user, notification_type="test_type", channels=["website", "email"] + ) + + channel = EmailChannel() + channel.process(notification) + + # Should send email immediately for realtime frequency + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, [self.user.email]) + + # Check notification was marked as sent + notification.refresh_from_db() + self.assertIsNotNone(notification.email_sent_at) + + def test_process_digest_frequency(self): + # Set user preference to daily (non-realtime) + EmailFrequency.objects.create(user=self.user, notification_type="test_type", frequency="daily") + + notification = Notification.objects.create( + recipient=self.user, notification_type="test_type", channels=["website", "email"] + ) + + channel = EmailChannel() + channel.process(notification) + + # Should not send email immediately for digest frequency + self.assertEqual(len(mail.outbox), 0) + + # Notification should not be marked as sent + notification.refresh_from_db() + self.assertIsNone(notification.email_sent_at) + + @override_settings(DEFAULT_FROM_EMAIL="test@example.com") + def test_send_email_now_basic(self): + notification = Notification.objects.create( + recipient=self.user, notification_type="test_type", subject="Test Subject", text="Test message" + ) + + channel = EmailChannel() + channel.send_email_now(notification) + + # Check email was sent + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, [self.user.email]) + self.assertEqual(email.subject, "Test Subject") + self.assertEqual(email.body, "Test message") + self.assertEqual(email.from_email, "test@example.com") + + # Check notification was marked as sent + notification.refresh_from_db() + self.assertIsNotNone(notification.email_sent_at) + + @override_settings(DEFAULT_FROM_EMAIL="test@example.com") + def test_send_email_now_uses_get_methods(self): + # Create notification without stored subject/text to test dynamic generation + notification = Notification.objects.create(recipient=self.user, notification_type="test_type") + + channel = EmailChannel() + channel.send_email_now(notification) + + # Check that email was sent using the get_subject/get_text methods + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + # The TestNotificationType returns empty strings for get_subject/get_text, + # so we should get the fallback values + self.assertEqual(email.subject, "A test notification type") + self.assertEqual(email.body, "") + + @override_settings(DEFAULT_FROM_EMAIL="test@example.com") + @patch("generic_notifications.channels.render_to_string") + def test_send_email_now_with_template(self, mock_render): + # Set up mock to return different values for different templates + def mock_render_side_effect(template_name, context): + if template_name.endswith("_subject.txt"): + return "Test Subject" + elif template_name.endswith(".html"): + return "Test HTML" + elif template_name.endswith(".txt"): + return "Test plain text" + return "" + + mock_render.side_effect = mock_render_side_effect + + notification = Notification.objects.create( + recipient=self.user, notification_type="test_type", subject="Test Subject", text="Test message" + ) + + channel = EmailChannel() + channel.send_email_now(notification) + + # Check templates were rendered (subject, HTML, then text) + self.assertEqual(mock_render.call_count, 3) + + # Check subject template call (first) + subject_call = mock_render.call_args_list[0] + self.assertEqual(subject_call[0][0], "notifications/email/realtime/test_type_subject.txt") + self.assertEqual(subject_call[0][1]["notification"], notification) + + # Check HTML template call (second) + html_call = mock_render.call_args_list[1] + self.assertEqual(html_call[0][0], "notifications/email/realtime/test_type.html") + self.assertEqual(html_call[0][1]["notification"], notification) + + # Check text template call (third) + text_call = mock_render.call_args_list[2] + self.assertEqual(text_call[0][0], "notifications/email/realtime/test_type.txt") + self.assertEqual(text_call[0][1]["notification"], notification) + + # Check email was sent with correct subject + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.subject, "Test Subject") + self.assertEqual(email.body, "Test plain text") + # HTML version should be in alternatives + self.assertEqual(len(email.alternatives), 1) # type: ignore + self.assertEqual(email.alternatives[0][0], "Test HTML") # type: ignore + + @override_settings(DEFAULT_FROM_EMAIL="test@example.com") + def test_send_email_now_template_error_fallback(self): + notification = Notification.objects.create( + recipient=self.user, notification_type="test_type", subject="Test Subject" + ) + + channel = EmailChannel() + channel.send_email_now(notification) + + # Should still send email without HTML + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.subject, "Test Subject") + self.assertEqual(len(email.alternatives), 0) # type: ignore[attr-defined] # No HTML alternative + + @override_settings(DEFAULT_FROM_EMAIL="test@example.com") + def test_send_digest_emails_empty_queryset(self): + # No notifications exist, so digest should not send anything + empty_notifications = Notification.objects.none() + EmailChannel.send_digest_emails(self.user, empty_notifications) + + # No email should be sent when no notifications exist + self.assertEqual(len(mail.outbox), 0) + + @override_settings(DEFAULT_FROM_EMAIL="test@example.com") + def test_send_digest_emails_basic(self): + # Set user to daily frequency to prevent realtime sending + EmailFrequency.objects.create(user=self.user, notification_type="test_type", frequency="daily") + + # Create test notifications without email_sent_at (unsent) + for i in range(3): + Notification.objects.create(recipient=self.user, notification_type="test_type", subject=f"Test {i}") + + # Get notifications as queryset + notifications = Notification.objects.filter(recipient=self.user, email_sent_at__isnull=True) + + # Send digest email for this user + EmailChannel.send_digest_emails(self.user, notifications) + + # Check email was sent + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, [self.user.email]) + self.assertIn("3 new notifications", email.subject) + + # Check all notifications marked as sent + for notification in notifications: + notification.refresh_from_db() + self.assertIsNotNone(notification.email_sent_at) + + @override_settings(DEFAULT_FROM_EMAIL="test@example.com") + def test_send_digest_emails_with_frequency(self): + # Set user to daily frequency to prevent realtime sending + EmailFrequency.objects.create(user=self.user, notification_type="test_type", frequency="daily") + + Notification.objects.create(recipient=self.user, notification_type="test_type", subject="Test") + + EmailChannel.send_digest_emails( + self.user, Notification.objects.filter(recipient=self.user, email_sent_at__isnull=True) + ) + + email = mail.outbox[0] + self.assertIn("1 new notifications", email.subject) + + @override_settings(DEFAULT_FROM_EMAIL="test@example.com") + def test_send_digest_emails_without_frequency(self): + # Set user to daily frequency to prevent realtime sending + EmailFrequency.objects.create(user=self.user, notification_type="test_type", frequency="daily") + + Notification.objects.create(recipient=self.user, notification_type="test_type", subject="Test") + + EmailChannel.send_digest_emails( + self.user, Notification.objects.filter(recipient=self.user, email_sent_at__isnull=True) + ) + + email = mail.outbox[0] + self.assertIn("Digest - 1 new notifications", email.subject) + + @override_settings(DEFAULT_FROM_EMAIL="test@example.com") + def test_send_digest_emails_text_limit(self): + # Set user to daily frequency to prevent realtime sending + EmailFrequency.objects.create(user=self.user, notification_type="test_type", frequency="daily") + + # Create more than 10 notifications to test text limit + _ = [ + Notification.objects.create(recipient=self.user, notification_type="test_type", subject=f"Test {i}") + for i in range(15) + ] + + EmailChannel.send_digest_emails( + self.user, Notification.objects.filter(recipient=self.user, email_sent_at__isnull=True) + ) + + # The implementation may not have this feature, so we'll just check that email was sent + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertIn("15 new notifications", email.subject) + + @override_settings(DEFAULT_FROM_EMAIL="test@example.com") + @patch("generic_notifications.channels.render_to_string") + def test_send_digest_emails_with_html_template(self, mock_render): + mock_render.return_value = "Digest HTML" + + # Set user to daily frequency to prevent realtime sending + EmailFrequency.objects.create(user=self.user, notification_type="test_type", frequency="daily") + + Notification.objects.create(recipient=self.user, notification_type="test_type", subject="Test") + + EmailChannel.send_digest_emails( + self.user, Notification.objects.filter(recipient=self.user, email_sent_at__isnull=True) + ) + + # Check templates were rendered (subject, HTML, then text) + self.assertEqual(mock_render.call_count, 3) + + # Check subject template call + subject_call = mock_render.call_args_list[0] + self.assertEqual(subject_call[0][0], "notifications/email/digest/subject.txt") + + # Check HTML template call + html_call = mock_render.call_args_list[1] + self.assertEqual(html_call[0][0], "notifications/email/digest/message.html") + + # Check text template call + text_call = mock_render.call_args_list[2] + self.assertEqual(text_call[0][0], "notifications/email/digest/message.txt") + self.assertEqual(html_call[0][1]["user"], self.user) + self.assertEqual(html_call[0][1]["count"], 1) diff --git a/tests/test_management_commands.py b/tests/test_management_commands.py new file mode 100644 index 0000000..e8967dc --- /dev/null +++ b/tests/test_management_commands.py @@ -0,0 +1,390 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.core import mail +from django.core.management import call_command +from django.test import TestCase +from django.utils import timezone + +from generic_notifications.frequencies import DailyFrequency, NotificationFrequency, RealtimeFrequency +from generic_notifications.models import EmailFrequency, Notification +from generic_notifications.registry import registry +from generic_notifications.types import NotificationType + +User = get_user_model() + + +class WeeklyFrequency(NotificationFrequency): + key = "weekly" + name = "Weekly" + is_realtime = False + description = "" + + +class TestNotificationType(NotificationType): + key = "test_type" + name = "Test Type" + description = "" + default_email_frequency = DailyFrequency # Defaults to daily like comments + + +class OtherNotificationType(NotificationType): + key = "other_type" + name = "Other Type" + description = "" + default_email_frequency = RealtimeFrequency # Defaults to realtime like system messages + + +class SendDigestEmailsCommandTest(TestCase): + def setUp(self): + self.user1 = User.objects.create_user(username="user1", email="user1@example.com", password="testpass") + self.user2 = User.objects.create_user(username="user2", email="user2@example.com", password="testpass") + self.user3 = User.objects.create_user(username="user3", email="user3@example.com", password="testpass") + + # Register test data + registry.register_type(TestNotificationType) + registry.register_type(OtherNotificationType) + + # Re-register frequencies in case they were cleared by other tests + registry.register_frequency(RealtimeFrequency, force=True) + registry.register_frequency(DailyFrequency, force=True) + registry.register_frequency(WeeklyFrequency) + + def tearDown(self): + mail.outbox.clear() + + def test_dry_run_option(self): + # Just test that dry-run option works without errors + call_command("send_digest_emails", "--frequency", "daily", "--dry-run") + + def test_no_digest_frequencies(self): + # Clear all frequencies and add only realtime + registry.unregister_frequency(DailyFrequency) + registry.register_frequency(RealtimeFrequency) + + # Should complete without sending any emails + call_command("send_digest_emails", "--frequency", "daily") + self.assertEqual(len(mail.outbox), 0) + + def test_target_frequency_not_found(self): + # Should complete without error when frequency not found (logging is handled internally) + call_command("send_digest_emails", "--frequency", "nonexistent") + + def test_target_frequency_is_realtime(self): + # Should complete without error when frequency is realtime (logging is handled internally) + call_command("send_digest_emails", "--frequency", "realtime") + + def test_send_digest_emails_basic_flow(self): + # Set up user with daily frequency preference + EmailFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") + + # Create a notification + notification = Notification.objects.create( + recipient=self.user1, + notification_type="test_type", + subject="Test notification", + text="This is a test notification", + channels=["email"], + ) + + # No emails should be in outbox initially + self.assertEqual(len(mail.outbox), 0) + + call_command("send_digest_emails", "--frequency", "daily") + + # Verify email was sent + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + + # Check email details + self.assertEqual(email.to, [self.user1.email]) + self.assertIn("1 new notifications", email.subject) + self.assertIn("Test notification", email.body) + + # Verify notification was marked as sent + notification.refresh_from_db() + self.assertIsNotNone(notification.email_sent_at) + + def test_dry_run_does_not_send_emails(self): + EmailFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") + + notification = Notification.objects.create( + recipient=self.user1, notification_type="test_type", subject="Test notification", channels=["email"] + ) + + # Ensure no emails in outbox initially + self.assertEqual(len(mail.outbox), 0) + + call_command("send_digest_emails", "--frequency", "daily", "--dry-run") + + # Should not send any emails in dry run + self.assertEqual(len(mail.outbox), 0) + + # Notification should not be marked as sent + notification.refresh_from_db() + self.assertIsNone(notification.email_sent_at) + + def test_only_includes_unread_notifications(self): + EmailFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") + + # Create read and unread notifications + read_notification = Notification.objects.create( + recipient=self.user1, notification_type="test_type", subject="Read notification", channels=["email"] + ) + read_notification.mark_as_read() + + unread_notification = Notification.objects.create( + recipient=self.user1, notification_type="test_type", subject="Unread notification", channels=["email"] + ) + + call_command("send_digest_emails", "--frequency", "daily") + + # Should send one email with only unread notification + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertIn("Unread notification", email.body) + self.assertNotIn("Read notification", email.body) + + # Only unread notification should be marked as sent + read_notification.refresh_from_db() + unread_notification.refresh_from_db() + + self.assertIsNone(read_notification.email_sent_at) # Still not sent + self.assertIsNotNone(unread_notification.email_sent_at) # Now sent + + def test_only_includes_unsent_notifications(self): + EmailFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") + + # Create sent and unsent notifications + Notification.objects.create( + recipient=self.user1, + notification_type="test_type", + subject="Sent notification", + email_sent_at=timezone.now(), + ) + + unsent_notification = Notification.objects.create( + recipient=self.user1, notification_type="test_type", subject="Unsent notification", channels=["email"] + ) + + call_command("send_digest_emails", "--frequency", "daily") + + # Should send one email with only unsent notification + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertIn("Unsent notification", email.body) + self.assertNotIn("Sent notification", email.body) + + # Unsent notification should now be marked as sent + unsent_notification.refresh_from_db() + self.assertIsNotNone(unsent_notification.email_sent_at) + + def test_sends_all_unsent_notifications(self): + EmailFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") + + # Create notification older than time window (>1 day ago) + old_notification = Notification.objects.create( + recipient=self.user1, notification_type="test_type", subject="Old notification", channels=["email"] + ) + # Manually set old timestamp + old_time = timezone.now() - timedelta(days=2) + Notification.objects.filter(id=old_notification.id).update(added=old_time) + + # Create recent notification + recent_notification = Notification.objects.create( + recipient=self.user1, notification_type="test_type", subject="Recent notification", channels=["email"] + ) + + call_command("send_digest_emails", "--frequency", "daily") + + # Should send one email with both notifications (no time window filtering) + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertIn("Recent notification", email.body) + self.assertIn("Old notification", email.body) + self.assertIn("2 new notifications", email.subject) + + # Both notifications should be marked as sent + old_notification.refresh_from_db() + recent_notification.refresh_from_db() + + self.assertIsNotNone(old_notification.email_sent_at) # Old but unsent, so included + self.assertIsNotNone(recent_notification.email_sent_at) # Recent, sent + + def test_specific_frequency_filter(self): + # Set up users with different frequency preferences + EmailFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") + EmailFrequency.objects.create(user=self.user2, notification_type="test_type", frequency="weekly") + + # Create notifications for both + Notification.objects.create( + recipient=self.user1, notification_type="test_type", subject="Daily user notification", channels=["email"] + ) + Notification.objects.create( + recipient=self.user2, notification_type="test_type", subject="Weekly user notification", channels=["email"] + ) + + call_command("send_digest_emails", "--frequency", "daily") + + # Should only send email to daily user + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, [self.user1.email]) + self.assertIn("Daily user notification", email.body) + + # Clear outbox and test weekly frequency + mail.outbox.clear() + call_command("send_digest_emails", "--frequency", "weekly") + + # Should only send email to weekly user + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, [self.user2.email]) + self.assertIn("Weekly user notification", email.body) + + def test_multiple_notification_types_for_user(self): + # Set up user with multiple notification types for daily frequency + EmailFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") + EmailFrequency.objects.create(user=self.user1, notification_type="other_type", frequency="daily") + + # Create notifications of both types + notification1 = Notification.objects.create( + recipient=self.user1, notification_type="test_type", subject="Test type notification", channels=["email"] + ) + notification2 = Notification.objects.create( + recipient=self.user1, notification_type="other_type", subject="Other type notification", channels=["email"] + ) + + call_command("send_digest_emails", "--frequency", "daily") + + # Should send one digest email with both notifications + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, [self.user1.email]) + self.assertIn("2 new notifications", email.subject) + self.assertIn("Test type notification", email.body) + self.assertIn("Other type notification", email.body) + + # Both notifications should be marked as sent + notification1.refresh_from_db() + notification2.refresh_from_db() + self.assertIsNotNone(notification1.email_sent_at) + self.assertIsNotNone(notification2.email_sent_at) + + def test_no_notifications_to_send(self): + EmailFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") + + # No notifications created + + call_command("send_digest_emails", "--frequency", "daily") + + # Should not send any emails + self.assertEqual(len(mail.outbox), 0) + + def test_users_with_disabled_email_channel_dont_get_digest(self): + """Test that users who disabled email channel for a type don't get digest emails.""" + # With the new architecture, if email is disabled, notifications won't have email channel + # So create a notification without email channel to simulate this + Notification.objects.create( + recipient=self.user1, notification_type="test_type", subject="Test notification", channels=["website"] + ) + + # Run daily digest - should not send anything (no email channel) + call_command("send_digest_emails", "--frequency", "daily") + self.assertEqual(len(mail.outbox), 0) + + def test_users_with_default_frequencies_get_digest(self): + """Test that users without explicit preferences get digest emails based on default frequencies.""" + # Don't create any EmailFrequency preferences - user will use defaults + + # Create a test_type notification (defaults to daily) + test_notification = Notification.objects.create( + recipient=self.user1, + notification_type="test_type", + subject="Test notification", + text="This is a test notification", + channels=["email"], + ) + + # Create an other_type notification (defaults to realtime) + other_notification = Notification.objects.create( + recipient=self.user1, + notification_type="other_type", + subject="Other notification", + text="This is another type of notification", + channels=["email"], + ) + + # Run daily digest - should include comment but not system message + call_command("send_digest_emails", "--frequency", "daily") + + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, [self.user1.email]) + self.assertIn("Test notification", email.body) + self.assertNotIn("Other notification", email.body) + + # Verify only test notification was marked as sent + test_notification.refresh_from_db() + other_notification.refresh_from_db() + self.assertIsNotNone(test_notification.email_sent_at) + self.assertIsNone(other_notification.email_sent_at) + + def test_mixed_explicit_and_default_preferences(self): + """Test that users with some explicit preferences and some defaults work correctly.""" + # User explicitly sets test_type to weekly + EmailFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="weekly") + # other_type will use its default (realtime) + + # Create notifications + Notification.objects.create( + recipient=self.user1, notification_type="test_type", subject="Test notification", channels=["email"] + ) + Notification.objects.create( + recipient=self.user1, notification_type="other_type", subject="Other notification", channels=["email"] + ) + + # Run daily digest - should get nothing (test_type is weekly, other_type is realtime) + call_command("send_digest_emails", "--frequency", "daily") + self.assertEqual(len(mail.outbox), 0) + + # Run weekly digest - should get test notification + call_command("send_digest_emails", "--frequency", "weekly") + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertIn("Test notification", email.body) + self.assertNotIn("Other notification", email.body) + + def test_multiple_users_default_and_explicit_mix(self): + """Test digest emails work correctly with multiple users having different preference configurations.""" + # user1: Uses all defaults (test_type=daily, other_type=realtime) + # user2: Explicit preference (test_type=weekly, other_type uses default=realtime) + # user3: Mixed (test_type=daily explicit, other_type uses default=realtime) + + EmailFrequency.objects.create(user=self.user2, notification_type="test_type", frequency="weekly") + EmailFrequency.objects.create(user=self.user3, notification_type="test_type", frequency="daily") + + # Create test notifications for all users + for i, user in enumerate([self.user1, self.user2, self.user3], 1): + Notification.objects.create( + recipient=user, + notification_type="test_type", + subject=f"Test notification for user {i}", + channels=["email"], + ) + + # Run daily digest - should get user1 and user3 (both have test_type=daily) + call_command("send_digest_emails", "--frequency", "daily") + self.assertEqual(len(mail.outbox), 2) + + recipients = {email.to[0] for email in mail.outbox} + self.assertIn(self.user1.email, recipients) + self.assertIn(self.user3.email, recipients) + self.assertNotIn(self.user2.email, recipients) + + # Clear outbox and run weekly digest - should get user2 + mail.outbox.clear() + call_command("send_digest_emails", "--frequency", "weekly") + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to[0], self.user2.email) + self.assertIn("Test notification for user 2", mail.outbox[0].body) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..405ca14 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,272 @@ +from typing import Any + +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.test import TestCase +from django.utils import timezone + +from generic_notifications.frequencies import DailyFrequency +from generic_notifications.models import DisabledNotificationTypeChannel, EmailFrequency, Notification +from generic_notifications.registry import registry +from generic_notifications.types import NotificationType + +User = get_user_model() + + +# Test subclasses for the ABC base classes +class TestNotificationType(NotificationType): + key = "test_type" + name = "Test Type" + description = "A test notification type" + + def get_subject(self, notification): + return "Test Subject" + + def get_text(self, notification): + return "Test notification text" + + +class TestChannel: + """Test channel for validation - simplified version""" + + key = "website" + name = "Website" + + +class DisabledNotificationTypeChannelModelTest(TestCase): + user: Any # User model instance created in setUpClass + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = User.objects.create_user(username="test", email="test@example.com", password="testpass") + + # Register test notification types and import channels for validation + registry.register_type(TestNotificationType) + + def test_create_disabled_notification(self): + disabled = DisabledNotificationTypeChannel.objects.create( + user=self.user, notification_type="test_type", channel="website" + ) + + self.assertEqual(disabled.user, self.user) + self.assertEqual(disabled.notification_type, "test_type") + self.assertEqual(disabled.channel, "website") + + def test_clean_with_invalid_notification_type(self): + disabled = DisabledNotificationTypeChannel(user=self.user, notification_type="invalid_type", channel="website") + + with self.assertRaises(ValidationError) as cm: + disabled.clean() + + self.assertIn("Unknown notification type: invalid_type", str(cm.exception)) + + def test_clean_with_invalid_channel(self): + disabled = DisabledNotificationTypeChannel( + user=self.user, notification_type="test_type", channel="invalid_channel" + ) + + with self.assertRaises(ValidationError) as cm: + disabled.clean() + + self.assertIn("Unknown channel: invalid_channel", str(cm.exception)) + + def test_clean_with_valid_data(self): + disabled = DisabledNotificationTypeChannel(user=self.user, notification_type="test_type", channel="website") + + # Should not raise any exception + disabled.clean() + + def test_clean_prevents_disabling_required_channel(self): + """Test that users cannot disable required channels for notification types""" + disabled = DisabledNotificationTypeChannel(user=self.user, notification_type="system_message", channel="email") + + with self.assertRaises(ValidationError) as cm: + disabled.clean() + + self.assertIn("Cannot disable email channel for System Message - this channel is required", str(cm.exception)) + + def test_clean_allows_disabling_non_required_channel(self): + """Test that users can disable non-required channels for notification types with required channels""" + disabled = DisabledNotificationTypeChannel( + user=self.user, notification_type="system_message", channel="website" + ) + + # Should not raise any exception - website is not required for system_message + disabled.clean() + + +class EmailFrequencyModelTest(TestCase): + user: Any # User model instance created in setUpClass + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = User.objects.create_user(username="test2", email="test2@example.com", password="testpass") + + # Register test data + registry.register_type(TestNotificationType) + # Re-register DailyFrequency in case it was cleared by other tests + registry.register_frequency(DailyFrequency, force=True) + + def test_create_email_frequency(self): + frequency = EmailFrequency.objects.create(user=self.user, notification_type="test_type", frequency="daily") + + self.assertEqual(frequency.user, self.user) + self.assertEqual(frequency.notification_type, "test_type") + self.assertEqual(frequency.frequency, "daily") + + def test_unique_together_constraint(self): + EmailFrequency.objects.create(user=self.user, notification_type="test_type", frequency="daily") + + with self.assertRaises(IntegrityError): + EmailFrequency.objects.create(user=self.user, notification_type="test_type", frequency="daily") + + def test_clean_with_invalid_notification_type(self): + frequency = EmailFrequency(user=self.user, notification_type="invalid_type", frequency="daily") + + with self.assertRaises(ValidationError) as cm: + frequency.clean() + + self.assertIn("Unknown notification type: invalid_type", str(cm.exception)) + + def test_clean_with_invalid_frequency(self): + frequency = EmailFrequency(user=self.user, notification_type="test_type", frequency="invalid_frequency") + + with self.assertRaises(ValidationError) as cm: + frequency.clean() + + self.assertIn("Unknown frequency: invalid_frequency", str(cm.exception)) + + def test_clean_with_valid_data(self): + frequency = EmailFrequency(user=self.user, notification_type="test_type", frequency="daily") + + # Should not raise any exception + frequency.clean() + + +class NotificationModelTest(TestCase): + user: Any # User model instance created in setUpClass + actor: Any # User model instance created in setUpClass + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = User.objects.create_user(username="test3", email="test3@example.com", password="testpass") + cls.actor = User.objects.create_user(username="actor", email="actor@example.com", password="testpass") + + # Register test notification type + registry.register_type(TestNotificationType) + + def test_create_minimal_notification(self): + notification = Notification.objects.create( + recipient=self.user, notification_type="test_type", channels=["website", "email"] + ) + + self.assertEqual(notification.recipient, self.user) + self.assertEqual(notification.notification_type, "test_type") + self.assertIsNotNone(notification.added) + self.assertIsNone(notification.read) + self.assertEqual(notification.metadata, {}) + + def test_create_full_notification(self): + notification = Notification.objects.create( + recipient=self.user, + notification_type="test_type", + subject="Test Subject", + text="Test notification text", + url="/test/url", + actor=self.actor, + metadata={"key": "value"}, + ) + + self.assertEqual(notification.recipient, self.user) + self.assertEqual(notification.notification_type, "test_type") + self.assertEqual(notification.subject, "Test Subject") + self.assertEqual(notification.text, "Test notification text") + self.assertEqual(notification.url, "/test/url") + self.assertEqual(notification.actor, self.actor) + self.assertEqual(notification.metadata, {"key": "value"}) + + def test_notification_with_generic_relation(self): + # Create a user to use as target object + target_user = User.objects.create_user(username="target", email="target@example.com", password="testpass") + content_type = ContentType.objects.get_for_model(User) + + notification = Notification.objects.create( + recipient=self.user, notification_type="test_type", content_type=content_type, object_id=target_user.id + ) + + self.assertEqual(notification.target, target_user) + + def test_clean_with_invalid_notification_type(self): + notification = Notification(recipient=self.user, notification_type="invalid_type") + + with self.assertRaises(ValidationError) as cm: + notification.clean() + + self.assertIn("Unknown notification type: invalid_type", str(cm.exception)) + + def test_clean_with_valid_notification_type(self): + notification = Notification(recipient=self.user, notification_type="test_type") + + # Should not raise any exception + notification.clean() + + def test_mark_as_read(self): + notification = Notification.objects.create( + recipient=self.user, notification_type="test_type", channels=["website", "email"] + ) + + self.assertFalse(notification.is_read) + self.assertIsNone(notification.read) + + notification.mark_as_read() + notification.refresh_from_db() + + self.assertTrue(notification.is_read) + self.assertIsNotNone(notification.read) + + def test_mark_as_read_idempotent(self): + notification = Notification.objects.create( + recipient=self.user, notification_type="test_type", channels=["website", "email"] + ) + + # Mark as read first time + notification.mark_as_read() + notification.refresh_from_db() + first_read_time = notification.read + + # Mark as read second time + notification.mark_as_read() + notification.refresh_from_db() + + # Should not change the read time + self.assertEqual(notification.read, first_read_time) + + def test_is_read_property(self): + notification = Notification.objects.create( + recipient=self.user, notification_type="test_type", channels=["website", "email"] + ) + + self.assertFalse(notification.is_read) + + notification.read = timezone.now() + self.assertTrue(notification.is_read) + + def test_email_sent_tracking(self): + notification = Notification.objects.create( + recipient=self.user, notification_type="test_type", channels=["website", "email"] + ) + + self.assertIsNone(notification.email_sent_at) + + # Simulate email being sent + sent_time = timezone.now() + notification.email_sent_at = sent_time + notification.save() + + notification.refresh_from_db() + self.assertEqual(notification.email_sent_at, sent_time) diff --git a/tests/test_performance.py b/tests/test_performance.py new file mode 100644 index 0000000..255b9f1 --- /dev/null +++ b/tests/test_performance.py @@ -0,0 +1,84 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from generic_notifications.channels import WebsiteChannel +from generic_notifications.models import Notification +from generic_notifications.utils import get_notifications + +User = get_user_model() + + +class NotificationPerformanceTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="test", email="test@example.com", password="testpass") + self.actor = User.objects.create_user(username="actor", email="actor@example.com", password="testpass") + + for i in range(5): + Notification.objects.create( + recipient=self.user, + actor=self.actor, + notification_type="test_notification", + subject=f"Test notification {i}", + text=f"This is test notification {i}", + channels=[WebsiteChannel.key], + url=f"/notification/{i}/", + ) + + def test_get_notifications_queries(self): + """Test the number of queries made by get_notifications""" + with self.assertNumQueries(1): + notifications = get_notifications(self.user) + # Force evaluation of the queryset + list(notifications) + + def test_notification_actor_access(self): + """Test that accessing actor doesn't cause additional queries""" + notifications = list(get_notifications(self.user)) + + with self.assertNumQueries(0): # Should be 0 since actor is select_related + for notification in notifications: + if notification.actor: + _ = notification.actor.email + + def test_notification_template_rendering_queries(self): + """Test queries when accessing notification attributes in template""" + notifications = get_notifications(self.user) + + # First, evaluate the queryset + notifications_list = list(notifications) + + # Now test accessing attributes - should be 0 queries if prefetched + with self.assertNumQueries(0): + for notification in notifications_list: + # Simulate template access patterns + _ = notification.is_read + _ = notification.notification_type + _ = notification.get_text() + _ = notification.url + _ = notification.added + _ = notification.id + + def test_notification_target_access_queries(self): + """Test queries when accessing notification.target in template""" + # Create notifications with targets + Notification.objects.all().delete() + for i in range(5): + Notification.objects.create( + recipient=self.user, + actor=self.actor, + notification_type="test_notification", + subject=f"Test notification {i}", + text=f"This is test notification {i}", + channels=[WebsiteChannel.key], + target=self.actor, + ) + + # First, evaluate the queryset + notifications = get_notifications(self.user) + notifications_list = list(notifications) + + # Test accessing target - this will cause queries + with self.assertNumQueries(5): # Expecting 5 queries + for notification in notifications_list: + if notification.target and hasattr(notification.target, "email"): + _ = notification.target.email diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..328dd5e --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,393 @@ +from django.test import TestCase + +from generic_notifications.channels import NotificationChannel +from generic_notifications.frequencies import NotificationFrequency +from generic_notifications.registry import NotificationRegistry +from generic_notifications.types import NotificationType + + +class TestNotificationType(NotificationType): + key = "test_key" + name = "Test Name" + description = "" + + +class TestNotificationTypeWithDescription(NotificationType): + key = "test_key" + name = "Test Name" + description = "Custom description" + + +class NotificationTypeTest(TestCase): + def test_create_notification_type(self): + notification_type = TestNotificationType() + + self.assertEqual(notification_type.key, "test_key") + self.assertEqual(notification_type.name, "Test Name") + self.assertEqual(notification_type.description, "") # empty by default + + def test_create_notification_type_with_description(self): + notification_type = TestNotificationTypeWithDescription() + + self.assertEqual(notification_type.key, "test_key") + self.assertEqual(notification_type.name, "Test Name") + self.assertEqual(notification_type.description, "Custom description") + + def test_notification_type_str(self): + notification_type = TestNotificationType() + self.assertEqual(str(notification_type), "Test Name") + + +class TestRealtimeFrequency(NotificationFrequency): + key = "realtime" + name = "Realtime" + is_realtime = True + description = "" + + +class TestDailyFrequency(NotificationFrequency): + key = "daily" + name = "Daily" + is_realtime = False + description = "" + + +class TestWeeklyFrequency(NotificationFrequency): + key = "weekly" + name = "Weekly" + is_realtime = False + description = "Once per week" + + +class TestDefaultFrequency(NotificationFrequency): + key = "test" + name = "Test" + is_realtime = False + description = "" + + +class NotificationFrequencyTest(TestCase): + def test_create_frequency_choice_realtime(self): + frequency = TestRealtimeFrequency() + + self.assertEqual(frequency.key, "realtime") + self.assertEqual(frequency.name, "Realtime") + self.assertTrue(frequency.is_realtime) + self.assertEqual(frequency.description, "") # empty by default + + def test_create_frequency_choice_digest(self): + frequency = TestDailyFrequency() + + self.assertEqual(frequency.key, "daily") + self.assertEqual(frequency.name, "Daily") + self.assertFalse(frequency.is_realtime) + + def test_create_frequency_choice_with_description(self): + frequency = TestWeeklyFrequency() + + self.assertEqual(frequency.key, "weekly") + self.assertEqual(frequency.name, "Weekly") + self.assertEqual(frequency.description, "Once per week") + + def test_frequency_choice_defaults(self): + frequency = TestDefaultFrequency() + + self.assertFalse(frequency.is_realtime) # defaults to False + self.assertEqual(frequency.description, "") # empty by default + + def test_frequency_choice_str(self): + frequency = TestDailyFrequency() + self.assertEqual(str(frequency), "Daily") + + +class NotificationRegistryTest(TestCase): + def setUp(self): + self.registry = NotificationRegistry() + + def test_create_empty_registry(self): + self.assertEqual(len(self.registry.get_all_types()), 0) + self.assertEqual(len(self.registry.get_all_channels()), 0) + self.assertEqual(len(self.registry.get_all_frequencies()), 0) + + def test_register_notification_type(self): + self.registry.register_type(TestNotificationType) + + # Registry returns instances, but they should be equal in content + registered_type = self.registry.get_type("test_key") + self.assertEqual(registered_type.key, "test_key") + self.assertEqual(registered_type.name, "Test Name") + self.assertEqual(len(self.registry.get_all_types()), 1) + + def test_register_invalid_notification_type(self): + with self.assertRaises(ValueError) as cm: + self.registry.register_type("not_a_type_object") # type: ignore[arg-type] + + self.assertIn("Must register a NotificationType subclass", str(cm.exception)) + + def test_register_channel(self): + class TestChannel(NotificationChannel): + key = "test" + name = "Test" + + def process(self, notification): + pass + + self.registry.register_channel(TestChannel) + + # Registry returns instances + registered_channel = self.registry.get_channel("test") + self.assertEqual(registered_channel.key, "test") + self.assertEqual(registered_channel.name, "Test") + self.assertEqual(len(self.registry.get_all_channels()), 1) + + def test_register_invalid_channel(self): + with self.assertRaises(ValueError) as cm: + self.registry.register_channel("not_a_channel_object") # type: ignore[arg-type] + + self.assertIn("Must register a NotificationChannel subclass", str(cm.exception)) + + def test_register_frequency(self): + self.registry.register_frequency(TestDailyFrequency) + + # Registry returns instances + registered_frequency = self.registry.get_frequency("daily") + self.assertEqual(registered_frequency.key, "daily") + self.assertEqual(registered_frequency.name, "Daily") + self.assertEqual(len(self.registry.get_all_frequencies()), 1) + + def test_register_invalid_frequency(self): + with self.assertRaises(ValueError) as cm: + self.registry.register_frequency("not_a_frequency_object") # type: ignore[arg-type] + + self.assertIn("Must register a NotificationFrequency subclass", str(cm.exception)) + + def test_get_nonexistent_items(self): + with self.assertRaises(KeyError): + self.registry.get_type("nonexistent") + with self.assertRaises(KeyError): + self.registry.get_channel("nonexistent") + with self.assertRaises(KeyError): + self.registry.get_frequency("nonexistent") + + def test_get_realtime_frequencies(self): + self.registry.register_frequency(TestRealtimeFrequency) + self.registry.register_frequency(TestDailyFrequency) + + realtime_frequencies = self.registry.get_realtime_frequencies() + + self.assertEqual(len(realtime_frequencies), 1) + self.assertEqual(realtime_frequencies[0].key, "realtime") + self.assertTrue(realtime_frequencies[0].is_realtime) + + def test_get_realtime_frequencies_empty(self): + self.registry.register_frequency(TestDailyFrequency) + + realtime_frequencies = self.registry.get_realtime_frequencies() + + self.assertEqual(len(realtime_frequencies), 0) + + def test_unregister_type(self): + self.registry.register_type(TestNotificationType) + + # Verify it's registered + self.assertIsNotNone(self.registry.get_type("test_key")) + + # Unregister it + result = self.registry.unregister_type(TestNotificationType) + + self.assertTrue(result) + with self.assertRaises(KeyError): + self.registry.get_type("test_key") + self.assertEqual(len(self.registry.get_all_types()), 0) + + def test_unregister_type_nonexistent(self): + class NonexistentType(NotificationType): + key = "nonexistent" + name = "Nonexistent Type" + description = "" + + result = self.registry.unregister_type(NonexistentType) + self.assertFalse(result) + + def test_unregister_channel(self): + class TestChannel(NotificationChannel): + key = "test" + name = "Test" + + def process(self, notification): + pass + + self.registry.register_channel(TestChannel) + + # Verify it's registered + self.assertIsNotNone(self.registry.get_channel("test")) + + # Unregister it + result = self.registry.unregister_channel(TestChannel) + + self.assertTrue(result) + with self.assertRaises(KeyError): + self.registry.get_channel("test") + self.assertEqual(len(self.registry.get_all_channels()), 0) + + def test_unregister_channel_nonexistent(self): + class NonexistentChannel(NotificationChannel): + key = "nonexistent" + name = "Nonexistent Channel" + + def process(self, notification): + pass + + result = self.registry.unregister_channel(NonexistentChannel) + self.assertFalse(result) + + def test_unregister_frequency(self): + self.registry.register_frequency(TestDailyFrequency) + + # Verify it's registered + self.assertIsNotNone(self.registry.get_frequency("daily")) + + # Unregister it + result = self.registry.unregister_frequency(TestDailyFrequency) + + self.assertTrue(result) + with self.assertRaises(KeyError): + self.registry.get_frequency("daily") + self.assertEqual(len(self.registry.get_all_frequencies()), 0) + + def test_unregister_frequency_nonexistent(self): + class NonexistentFrequency(NotificationFrequency): + key = "nonexistent" + name = "Nonexistent Frequency" + is_realtime = False + description = "" + + result = self.registry.unregister_frequency(NonexistentFrequency) + self.assertFalse(result) + + def test_clear_types(self): + # Register some types + class Type1(NotificationType): + key = "type1" + name = "Type 1" + description = "" + + class Type2(NotificationType): + key = "type2" + name = "Type 2" + description = "" + + self.registry.register_type(Type1) + self.registry.register_type(Type2) + + self.assertEqual(len(self.registry.get_all_types()), 2) + + # Clear all types + self.registry.clear_types() + + self.assertEqual(len(self.registry.get_all_types()), 0) + with self.assertRaises(KeyError): + self.registry.get_type("type1") + with self.assertRaises(KeyError): + self.registry.get_type("type2") + + def test_clear_channels(self): + class Channel1(NotificationChannel): + key = "channel1" + name = "Channel 1" + + def process(self, notification): + pass + + class Channel2(NotificationChannel): + key = "channel2" + name = "Channel 2" + + def process(self, notification): + pass + + # Register some channels + self.registry.register_channel(Channel1) + self.registry.register_channel(Channel2) + + self.assertEqual(len(self.registry.get_all_channels()), 2) + + # Clear all channels + self.registry.clear_channels() + + self.assertEqual(len(self.registry.get_all_channels()), 0) + with self.assertRaises(KeyError): + self.registry.get_channel("channel1") + with self.assertRaises(KeyError): + self.registry.get_channel("channel2") + + def test_clear_frequencies(self): + class Freq1(NotificationFrequency): + key = "freq1" + name = "Frequency 1" + is_realtime = False + description = "" + + class Freq2(NotificationFrequency): + key = "freq2" + name = "Frequency 2" + is_realtime = False + description = "" + + # Register some frequencies + self.registry.register_frequency(Freq1) + self.registry.register_frequency(Freq2) + + self.assertEqual(len(self.registry.get_all_frequencies()), 2) + + # Clear all frequencies + self.registry.clear_frequencies() + + self.assertEqual(len(self.registry.get_all_frequencies()), 0) + with self.assertRaises(KeyError): + self.registry.get_frequency("freq1") + with self.assertRaises(KeyError): + self.registry.get_frequency("freq2") + + def test_multiple_registrations_replace(self): + class Type1(NotificationType): + key = "test_type" + name = "Test Type 1" + description = "" + + class Type2(NotificationType): + key = "test_type" + name = "Test Type 2" + description = "" + + # Register a type + self.registry.register_type(Type1) + + # Register another type with same key (should replace) + self.registry.register_type(Type2) + + # Should have the second type + registered_type = self.registry.get_type("test_type") + self.assertEqual(registered_type.name, "Test Type 2") + self.assertEqual(len(self.registry.get_all_types()), 1) + + def test_get_all_methods_return_lists(self): + # Test that get_all methods return lists, not dict values + self.registry.register_type(TestNotificationType) + + types = self.registry.get_all_types() + self.assertIsInstance(types, list) + self.assertEqual(types[0].key, "test_key") + + def test_registry_isolation(self): + # Create two different registry instances + registry1 = NotificationRegistry() + registry2 = NotificationRegistry() + + # Register type in first registry only + registry1.register_type(TestNotificationType) + + # Should not affect second registry + self.assertIsNotNone(registry1.get_type("test_key")) + with self.assertRaises(KeyError): + registry2.get_type("test_key") diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..af1d14c --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,446 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from generic_notifications import send_notification +from generic_notifications.channels import WebsiteChannel +from generic_notifications.models import Notification +from generic_notifications.registry import registry +from generic_notifications.types import NotificationType +from generic_notifications.utils import ( + get_notifications, + get_unread_count, + mark_notifications_as_read, +) + +User = get_user_model() + + +class TestNotificationType(NotificationType): + key = "test_type" + name = "Test Type" + description = "" + + +class OtherNotificationType(NotificationType): + key = "other_type" + name = "Other Type" + description = "" + + +class GroupingNotificationType(NotificationType): + key = "grouping_type" + name = "Grouping Type" + description = "Notification that supports grouping" + + @classmethod + def should_save(cls, notification): + # Look for existing unread notification with same actor and target + existing = Notification.objects.filter( + recipient=notification.recipient, + notification_type=notification.notification_type, + actor=notification.actor, + content_type_id=notification.content_type_id, + object_id=notification.object_id, + read__isnull=True, + ).first() + + if existing: + # Update count in metadata + count = existing.metadata.get("count", 1) + existing.metadata["count"] = count + 1 + existing.save() + return False + + return True + + +class SendNotificationTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="test", email="test@example.com", password="testpass") + self.actor = User.objects.create_user(username="actor", email="actor@example.com", password="testpass") + + # Register test notification type + self.notification_type = TestNotificationType + registry.register_type(TestNotificationType) + + def test_send_notification_basic(self): + notification = send_notification( + recipient=self.user, notification_type=self.notification_type, subject="Test Subject", text="Test message" + ) + + self.assertIsInstance(notification, Notification) + self.assertEqual(notification.recipient, self.user) + self.assertEqual(notification.notification_type, "test_type") + self.assertEqual(notification.subject, "Test Subject") + self.assertEqual(notification.text, "Test message") + self.assertIsNone(notification.actor) + self.assertIsNone(notification.target) + + def test_send_notification_with_actor_and_target(self): + notification = send_notification( + recipient=self.user, + notification_type=self.notification_type, + actor=self.actor, + target=self.actor, # Using actor as target for simplicity + url="/test/url", + metadata={"key": "value"}, + ) + + self.assertEqual(notification.actor, self.actor) + self.assertEqual(notification.target, self.actor) + self.assertEqual(notification.url, "/test/url") + self.assertEqual(notification.metadata, {"key": "value"}) + + def test_send_notification_invalid_type(self): + class InvalidNotificationType(NotificationType): + key = "invalid_type" + name = "Invalid Type" + description = "" + + invalid_type = InvalidNotificationType + with self.assertRaises(ValueError) as cm: + send_notification(recipient=self.user, notification_type=invalid_type) + + self.assertIn("not registered", str(cm.exception)) + + def test_send_notification_processes_channels(self): + notification = send_notification(recipient=self.user, notification_type=self.notification_type) + + # Verify notification was created with channels + self.assertIsNotNone(notification) + self.assertIn("website", notification.channels) + self.assertIn("email", notification.channels) + self.assertEqual(notification.notification_type, "test_type") + + def test_send_notification_multiple_channels(self): + notification = send_notification(recipient=self.user, notification_type=self.notification_type) + + # Notification should have both channels enabled + self.assertIn("website", notification.channels) + self.assertIn("email", notification.channels) + self.assertEqual(len(notification.channels), 2) + + +class MarkNotificationsAsReadTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="test", email="test@example.com", password="testpass") + registry.register_type(TestNotificationType) + + # Create test notifications + self.notification1 = Notification.objects.create( + recipient=self.user, notification_type="test_type", subject="First" + ) + self.notification2 = Notification.objects.create( + recipient=self.user, notification_type="test_type", subject="Second" + ) + self.notification3 = Notification.objects.create( + recipient=self.user, notification_type="test_type", subject="Third" + ) + + def test_mark_all_notifications_as_read(self): + # All should be unread initially + self.assertFalse(self.notification1.is_read) + self.assertFalse(self.notification2.is_read) + self.assertFalse(self.notification3.is_read) + + mark_notifications_as_read(self.user) + + # Refresh from database + self.notification1.refresh_from_db() + self.notification2.refresh_from_db() + self.notification3.refresh_from_db() + + # All should now be read + self.assertTrue(self.notification1.is_read) + self.assertTrue(self.notification2.is_read) + self.assertTrue(self.notification3.is_read) + + def test_mark_specific_notifications_as_read(self): + notification_ids = [self.notification1.id, self.notification3.id] + + mark_notifications_as_read(self.user, notification_ids) + + # Refresh from database + self.notification1.refresh_from_db() + self.notification2.refresh_from_db() + self.notification3.refresh_from_db() + + # Only specified notifications should be read + self.assertTrue(self.notification1.is_read) + self.assertFalse(self.notification2.is_read) + self.assertTrue(self.notification3.is_read) + + def test_mark_already_read_notifications(self): + # Mark one as read first + self.notification1.mark_as_read() + original_read_time = self.notification1.read + + mark_notifications_as_read(self.user) + + self.notification1.refresh_from_db() + # Read time should not change for already read notifications + self.assertEqual(self.notification1.read, original_read_time) + + def test_mark_notifications_other_user_not_affected(self): + other_user = User.objects.create_user(username="other", email="other@example.com", password="testpass") + other_notification = Notification.objects.create( + recipient=other_user, notification_type="test_type", channels=["website", "email"] + ) + + mark_notifications_as_read(self.user) + + other_notification.refresh_from_db() + # Other user's notifications should not be affected + self.assertFalse(other_notification.is_read) + + +class GetUnreadCountTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="test", email="test@example.com", password="testpass") + registry.register_type(TestNotificationType) + + def test_get_unread_count_empty(self): + count = get_unread_count(self.user, channel=WebsiteChannel) + self.assertEqual(count, 0) + + def test_get_unread_count_with_unread_notifications(self): + # Create unread notifications + Notification.objects.create(recipient=self.user, notification_type="test_type", channels=["website"]) + Notification.objects.create(recipient=self.user, notification_type="test_type", channels=["website"]) + Notification.objects.create(recipient=self.user, notification_type="test_type", channels=["website"]) + + count = get_unread_count(self.user, channel=WebsiteChannel) + self.assertEqual(count, 3) + + def test_get_unread_count_with_mix_of_read_unread(self): + # Create mix of read and unread + notification1 = Notification.objects.create( + recipient=self.user, notification_type="test_type", channels=["website"] + ) + Notification.objects.create(recipient=self.user, notification_type="test_type", channels=["website"]) + notification3 = Notification.objects.create( + recipient=self.user, notification_type="test_type", channels=["website"] + ) + + # Mark some as read + notification1.mark_as_read() + notification3.mark_as_read() + + count = get_unread_count(self.user, channel=WebsiteChannel) + self.assertEqual(count, 1) + + def test_get_unread_count_other_user_not_counted(self): + other_user = User.objects.create_user(username="other", email="other@example.com", password="testpass") + + # Create notifications for both users + Notification.objects.create(recipient=self.user, notification_type="test_type", channels=["website"]) + Notification.objects.create(recipient=other_user, notification_type="test_type", channels=["website", "email"]) + + count = get_unread_count(self.user, channel=WebsiteChannel) + self.assertEqual(count, 1) + + +class GetNotificationsForUserTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="test", email="test@example.com", password="testpass") + registry.register_type(TestNotificationType) + registry.register_type(OtherNotificationType) + + # Create test notifications + self.notification1 = Notification.objects.create( + recipient=self.user, notification_type="test_type", subject="Test 1", channels=["website", "email"] + ) + self.notification2 = Notification.objects.create( + recipient=self.user, notification_type="other_type", subject="Other 1", channels=["website", "email"] + ) + self.notification3 = Notification.objects.create( + recipient=self.user, notification_type="test_type", subject="Test 2", channels=["website", "email"] + ) + + # Mark one as read + self.notification2.mark_as_read() + + def test_get_all_notifications(self): + notifications = get_notifications(self.user, channel=WebsiteChannel) + + self.assertEqual(notifications.count(), 3) + # Should be ordered by -added (newest first) + self.assertEqual(list(notifications), [self.notification3, self.notification2, self.notification1]) + + def test_get_unread_only(self): + notifications = get_notifications(self.user, channel=WebsiteChannel, unread_only=True) + + self.assertEqual(notifications.count(), 2) + # Should not include the read notification + self.assertIn(self.notification1, notifications) + self.assertNotIn(self.notification2, notifications) + self.assertIn(self.notification3, notifications) + + def test_get_with_limit(self): + notifications = get_notifications(self.user, channel=WebsiteChannel, limit=2) + + self.assertEqual(notifications.count(), 2) + # Should get the first 2 (newest) + self.assertEqual(list(notifications), [self.notification3, self.notification2]) + + def test_get_notifications_other_user_not_included(self): + other_user = User.objects.create_user(username="other", email="other@example.com", password="testpass") + Notification.objects.create(recipient=other_user, notification_type="test_type", channels=["website", "email"]) + + notifications = get_notifications(self.user, channel=WebsiteChannel) + + self.assertEqual(notifications.count(), 3) # Only this user's notifications + + +class NotificationGroupingTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="test", email="test@example.com", password="testpass") + self.actor = User.objects.create_user(username="actor", email="actor@example.com", password="testpass") + self.target = User.objects.create_user(username="target", email="target@example.com", password="testpass") + + # Register the grouping notification type + registry.register_type(GroupingNotificationType) + + def test_should_save_grouping_behavior(self): + # First notification should be created + notification1 = send_notification( + recipient=self.user, + notification_type=GroupingNotificationType, + actor=self.actor, + target=self.target, + text="First comment", + ) + + self.assertIsNotNone(notification1) + + # Second notification should be grouped + notification2 = send_notification( + recipient=self.user, + notification_type=GroupingNotificationType, + actor=self.actor, + target=self.target, + text="Second comment", + ) + + # No new notification created + self.assertIsNone(notification2) + + # Original notification should be updated + notification1.refresh_from_db() + self.assertEqual(notification1.metadata["count"], 2) + + # Still only one notification in database + self.assertEqual(Notification.objects.filter(recipient=self.user, notification_type="grouping_type").count(), 1) + + def test_grouping_with_different_actors(self): + actor2 = User.objects.create_user(username="actor2", email="actor2@example.com", password="testpass") + + # First notification + notification1 = send_notification( + recipient=self.user, + notification_type=GroupingNotificationType, + actor=self.actor, + target=self.target, + ) + + # Different actor - should create new notification + notification2 = send_notification( + recipient=self.user, + notification_type=GroupingNotificationType, + actor=actor2, + target=self.target, + ) + + self.assertIsNotNone(notification1) + self.assertIsNotNone(notification2) + self.assertNotEqual(notification1.id, notification2.id) + self.assertEqual(Notification.objects.filter(recipient=self.user, notification_type="grouping_type").count(), 2) + + def test_grouping_with_different_targets(self): + target2 = User.objects.create_user(username="target2", email="target2@example.com", password="testpass") + + # First notification + notification1 = send_notification( + recipient=self.user, + notification_type=GroupingNotificationType, + actor=self.actor, + target=self.target, + ) + + # Different target - should create new notification + notification2 = send_notification( + recipient=self.user, + notification_type=GroupingNotificationType, + actor=self.actor, + target=target2, + ) + + self.assertIsNotNone(notification1) + self.assertIsNotNone(notification2) + self.assertNotEqual(notification1.id, notification2.id) + self.assertEqual(Notification.objects.filter(recipient=self.user, notification_type="grouping_type").count(), 2) + + def test_grouping_ignores_read_notifications(self): + # First notification + notification1 = send_notification( + recipient=self.user, + notification_type=GroupingNotificationType, + actor=self.actor, + target=self.target, + ) + + # Mark it as read + notification1.mark_as_read() + + # Second notification should create new one (not group with read notification) + notification2 = send_notification( + recipient=self.user, + notification_type=GroupingNotificationType, + actor=self.actor, + target=self.target, + ) + + self.assertIsNotNone(notification1) + self.assertIsNotNone(notification2) + self.assertNotEqual(notification1.id, notification2.id) + + def test_grouping_updates_metadata_multiple_times(self): + # Create first notification + notification1 = send_notification( + recipient=self.user, + notification_type=GroupingNotificationType, + actor=self.actor, + target=self.target, + ) + + # Send 3 more notifications + for i in range(3): + result = send_notification( + recipient=self.user, + notification_type=GroupingNotificationType, + actor=self.actor, + target=self.target, + ) + self.assertIsNone(result) + + # Check count was incremented correctly + notification1.refresh_from_db() + self.assertEqual(notification1.metadata["count"], 4) + + @patch("generic_notifications.types.NotificationType.should_save") + def test_transaction_atomicity(self, mock_should_save): + # Simulate should_save raising an exception + mock_should_save.side_effect = Exception("Database error") + + with self.assertRaises(Exception): + send_notification( + recipient=self.user, + notification_type=TestNotificationType, + actor=self.actor, + target=self.target, + ) + + # No notification should be created due to transaction rollback + self.assertEqual(Notification.objects.filter(recipient=self.user).count(), 0)