Skip to content

Commit e5b9ba2

Browse files
authored
Merge pull request #1 from vintasoftware/feat/attachments-and-one-off-notifications
Implement Attachments and one-off notifications
2 parents ce0c2fb + 64563fb commit e5b9ba2

File tree

13 files changed

+1811
-309
lines changed

13 files changed

+1811
-309
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: CI
22

3-
on: [push, pull_request]
3+
on: [push]
44

55
jobs:
66
build-python:

.github/workflows/publish.yml

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
name: Publish Package
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*' # Triggers on tags like v1.0.0, v2.1.3, etc.
7+
8+
jobs:
9+
test-before-publish:
10+
name: Run tests before publishing
11+
runs-on: ubuntu-latest
12+
13+
strategy:
14+
matrix:
15+
python-version: ["3.10", "3.11", "3.12", "3.13"]
16+
17+
steps:
18+
- name: Checkout code
19+
uses: actions/checkout@v4
20+
21+
- name: Set up Python ${{ matrix.python-version }}
22+
uses: actions/setup-python@v5
23+
with:
24+
python-version: ${{ matrix.python-version }}
25+
26+
- name: Install dependencies
27+
run: |
28+
python -m pip install --upgrade pip
29+
pip install poetry
30+
poetry install
31+
32+
- name: Run tests
33+
run: poetry run tox
34+
env:
35+
OPENAI_API_KEY: "sk-fake-test-key-123"
36+
37+
# Make sure tests pass before publishing
38+
check-tests:
39+
name: Check that tests pass
40+
runs-on: ubuntu-latest
41+
needs: test-before-publish
42+
steps:
43+
- name: Tests passed
44+
run: echo "All tests passed successfully"
45+
46+
# Publish only after tests pass
47+
publish-release:
48+
name: Publish release
49+
needs: [test-before-publish, check-tests]
50+
runs-on: ubuntu-latest
51+
if: ${{ needs.test-before-publish.result == 'success' }}
52+
53+
permissions:
54+
contents: write # Needed to create GitHub releases
55+
id-token: write # Needed for trusted publishing to PyPI
56+
57+
steps:
58+
- name: Checkout code
59+
uses: actions/checkout@v4
60+
with:
61+
fetch-depth: 0 # Fetch full history for proper version detection
62+
63+
- name: Set up Python
64+
uses: actions/setup-python@v5
65+
with:
66+
python-version: "3.12" # Use a stable Python version for publishing
67+
68+
- name: Install Poetry
69+
uses: snok/install-poetry@v1
70+
with:
71+
version: latest
72+
virtualenvs-create: true
73+
virtualenvs-in-project: true
74+
75+
- name: Cache Poetry dependencies
76+
uses: actions/cache@v4
77+
with:
78+
path: .venv
79+
key: poetry-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
80+
81+
- name: Install dependencies
82+
run: poetry install --only=main
83+
84+
- name: Build package
85+
run: poetry build
86+
87+
- name: Verify package contents
88+
run: |
89+
# List the built packages
90+
ls -la dist/
91+
# Check package contents
92+
poetry run pip install twine
93+
poetry run twine check dist/*
94+
95+
- name: Publish to PyPI
96+
env:
97+
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }}
98+
run: |
99+
poetry config pypi-token.pypi $POETRY_PYPI_TOKEN_PYPI
100+
poetry publish

RELEASE_NOTES.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Release Notes
2+
3+
## Version 1.0.0 (2025-09-16)
4+
5+
### 🚀 Major Features
6+
7+
* Upgrade vintasend to version 1.0.0
8+
* Support one-off notifications (without user)
9+
* Support attachements
10+
11+
---
12+
13+
## Version 0.1.4 (Initial Release)
14+
15+
Initial version of VintaSend with core notification functionality.

poetry.lock

Lines changed: 472 additions & 264 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ include = [
1313
[tool.poetry.dependencies]
1414
python = "<3.14,>=3.10"
1515
django = "<5.3,>=3.2"
16-
vintasend = "0.1.4"
16+
vintasend = "1.0.0"
1717
django-model-utils = "^5.0.0"
1818

1919

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Generated by Django 5.2.6 on 2025-09-16 18:23
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
import model_utils.fields
7+
8+
9+
class Migration(migrations.Migration):
10+
dependencies = [
11+
("vintasend_django", "0001_initial"),
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name="notification",
18+
name="email_or_phone",
19+
field=models.CharField(blank=True, max_length=255),
20+
),
21+
migrations.AddField(
22+
model_name="notification",
23+
name="first_name",
24+
field=models.CharField(blank=True, max_length=255),
25+
),
26+
migrations.AddField(
27+
model_name="notification",
28+
name="last_name",
29+
field=models.CharField(blank=True, max_length=255),
30+
),
31+
migrations.AlterField(
32+
model_name="notification",
33+
name="user",
34+
field=models.ForeignKey(
35+
blank=True,
36+
null=True,
37+
on_delete=django.db.models.deletion.CASCADE,
38+
to=settings.AUTH_USER_MODEL,
39+
),
40+
),
41+
migrations.CreateModel(
42+
name="Attachment",
43+
fields=[
44+
(
45+
"id",
46+
models.BigAutoField(
47+
auto_created=True,
48+
primary_key=True,
49+
serialize=False,
50+
verbose_name="ID",
51+
),
52+
),
53+
("file", models.FileField(upload_to="notifications/attachments/")),
54+
("name", models.CharField(max_length=255)),
55+
("mime_type", models.CharField(blank=True, max_length=255)),
56+
("size", models.PositiveIntegerField(null=True)),
57+
(
58+
"created",
59+
model_utils.fields.AutoCreatedField(
60+
db_index=True,
61+
default=django.utils.timezone.now,
62+
editable=False,
63+
verbose_name="created",
64+
),
65+
),
66+
(
67+
"modified",
68+
model_utils.fields.AutoLastModifiedField(
69+
db_index=True,
70+
default=django.utils.timezone.now,
71+
editable=False,
72+
verbose_name="modified",
73+
),
74+
),
75+
(
76+
"notification",
77+
models.ForeignKey(
78+
on_delete=django.db.models.deletion.CASCADE,
79+
related_name="attachments",
80+
to="vintasend_django.notification",
81+
),
82+
),
83+
],
84+
options={
85+
"ordering": ("-created",),
86+
},
87+
),
88+
]

vintasend_django/models.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
User = get_user_model()
1111

1212
class Notification(models.Model):
13-
user = models.ForeignKey(User, on_delete=models.CASCADE)
13+
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
14+
email_or_phone = models.CharField(max_length=255, blank=True)
15+
first_name = models.CharField(max_length=255, blank=True)
16+
last_name = models.CharField(max_length=255, blank=True)
1417
notification_type = models.CharField(max_length=50, choices=NotificationTypesChoices)
1518
title = models.CharField(max_length=255)
1619
status = models.CharField(
@@ -41,3 +44,22 @@ class Meta:
4144

4245
def __str__(self):
4346
return f"{self.user} - {self.notification_type} - {self.title} - {self.status}{f' (scheduled to {self.send_after})' if self.send_after else ''}"
47+
48+
49+
class Attachment(models.Model):
50+
notification = models.ForeignKey(Notification, on_delete=models.CASCADE, related_name="attachments")
51+
file = models.FileField(upload_to="notifications/attachments/")
52+
name = models.CharField(max_length=255)
53+
mime_type = models.CharField(max_length=255, blank=True)
54+
size = models.PositiveIntegerField(null=True)
55+
56+
created = AutoCreatedField(_("created"), db_index=True)
57+
modified = AutoLastModifiedField(_("modified"), db_index=True)
58+
59+
objects: models.Manager["Attachment"]
60+
61+
class Meta:
62+
ordering = ("-created",)
63+
64+
def __str__(self):
65+
return f"{self.name} ({self.notification})"
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from typing import BinaryIO
2+
3+
from vintasend.services.dataclasses import AttachmentFile
4+
5+
from vintasend_django.models import Attachment
6+
7+
8+
class DjangoAttachmentFile(AttachmentFile):
9+
"""Django-specific implementation for file access"""
10+
11+
def __init__(self, attachment: Attachment):
12+
self.attachment = attachment
13+
14+
@property
15+
def name(self) -> str:
16+
"""Get the attachment name"""
17+
return self.attachment.name
18+
19+
@property
20+
def mime_type(self) -> str:
21+
"""Get the attachment MIME type"""
22+
return self.attachment.mime_type
23+
24+
def read(self) -> bytes:
25+
"""Read the entire file content"""
26+
self.attachment.file.seek(0)
27+
return self.attachment.file.read()
28+
29+
def stream(self) -> BinaryIO:
30+
"""
31+
Return a new file stream for large files.
32+
33+
Each call to this method opens a new file handle.
34+
The caller is responsible for closing the returned stream to prevent resource leaks.
35+
"""
36+
return self.attachment.file.open('rb')
37+
38+
def url(self, expires_in: int = 3600) -> str:
39+
"""Generate temporary URL if supported"""
40+
return self.attachment.file.url
41+
42+
def delete(self) -> None:
43+
"""Delete from storage"""
44+
if self.attachment.file:
45+
self.attachment.file.delete(save=False)
46+
self.attachment.delete()

0 commit comments

Comments
 (0)