Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
af7583b
check metadata field of SpecialMembershipDataset Model
danieleguido Apr 21, 2026
2f44465
Add temporary approval to membership requests
danieleguido Apr 21, 2026
d71176a
Update settings.py
danieleguido Apr 21, 2026
98ff7c0
temporary request (wip)
danieleguido Apr 21, 2026
bee5c84
membership revoked template (WIP)
danieleguido Apr 21, 2026
fa82f95
Create 0058_remove_userrequest_subscription_and_more.py
danieleguido Apr 21, 2026
2734c2c
WIP
danieleguido Apr 21, 2026
9424393
Add temp membership check & revocation beat
danieleguido May 4, 2026
0847dc8
revoke after days metadata accepts float values
danieleguido May 5, 2026
c637e67
Update AGENTS.md
danieleguido May 5, 2026
195cdcb
add temporary_expires_at field in userSpecialMembershipRequest
danieleguido May 5, 2026
06f027f
add custom command to create SMR
danieleguido May 5, 2026
2181263
add status label to email templates and add this to specific command
danieleguido May 6, 2026
d8680cc
align templates HTML and TXT
danieleguido May 6, 2026
7aac236
fix tests
danieleguido May 6, 2026
376bf05
Update test_userSpecialMembershipRequest.py
danieleguido May 6, 2026
c8e638d
Update test_userSpecialMembershipRequest.py
danieleguido May 6, 2026
26e3596
Make temporary revocations beat-driven
danieleguido May 7, 2026
04c59f6
Potential fix for pull request finding
danieleguido May 7, 2026
4e3261c
Update user_special_membership_request_approved_temporary_to_user.html
danieleguido May 7, 2026
c3807fc
Merge branch 'feature/temporary-subscription' of https://github.com/i…
danieleguido May 7, 2026
b489d01
Update userSpecialMembershipRequest.py
danieleguido May 7, 2026
584815e
Use settings default for temporary revoke days
danieleguido May 7, 2026
bb4e84b
Update settings.py
danieleguido May 7, 2026
f65eb83
specialMembershipDataset: add common method on model directly
danieleguido May 7, 2026
b3356b4
Update .gitignore
danieleguido May 7, 2026
1c9fa87
use newly created methods
danieleguido May 7, 2026
926bb48
remove celery beat
danieleguido May 7, 2026
2588ecc
exclude 0 when changing revoke_after_days property in the admin
danieleguido May 7, 2026
22bf338
Create 0059_alter_collection_serialized_search_query_and_more.py
danieleguido May 7, 2026
ca97983
refine admin
danieleguido May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,5 @@ ___*

# local docker settings and config
.docker/*
!.docker/.gitkeep
!.docker/.gitkeep
*.db
56 changes: 55 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,67 @@ This repository contains AI coding instructions and architectural guidelines in:
- `.github/copilot-instructions.md`

Those instructions define:

- Coding style
- Task conventions
- Architectural constraints

All agents and contributors MUST follow those rules when adding or modifying tasks.

## Testing and Celery Behavior

### Custom test runner

The project uses a custom Django test runner configured in [impresso/settings.py](impresso/settings.py#L104):

- `TEST_RUNNER = "impresso.tests.test_runner.TestRunner"`

The runner implementation is in [impresso/tests/test_runner.py](impresso/tests/test_runner.py#L1) and enables these Celery settings for tests:

- `CELERY_TASK_ALWAYS_EAGER=True`
- `CELERY_TASK_EAGER_PROPAGATES=True`
- `CELERY_TASK_STORE_EAGER_RESULT=False`

### Practical implications for tests

- Calls to Celery queue APIs like `.delay()` and `.apply_async()` run inline during tests in this repository.
- Exceptions raised by task code propagate to the test process (because eager propagates is enabled).
- For scheduling logic, do not wait for wall-clock execution in tests. Instead, patch the enqueue call and assert arguments.

### `apply_async(..., eta=...)` in tests

When eager mode is enabled by the custom test runner, `apply_async(..., eta=...)` does not behave like a real delayed worker schedule. The call executes immediately in-process, while `eta` is still passed as metadata.

For scheduling assertions, patch the task method and assert it was called with the expected `eta` and kwargs, for example:

```python
from unittest.mock import patch

with patch(
"impresso.tasks.userSpecialMembershipRequest_tasks.revoke_special_membership_request.apply_async"
) as mock_apply_async:
_schedule_temporary_revocation(req)

mock_apply_async.assert_called_once()
_, call_kwargs = mock_apply_async.call_args
assert call_kwargs["kwargs"] == {"instance_id": req.pk}
assert "eta" in call_kwargs
```

### Temporary special membership revocation strategy

Temporary special membership revocation is beat-driven in this repository:

- Requests are marked with `temporary_expires_at` when temporarily approved.
- Periodic task `revoke_expired_temporary_memberships_beat` is the single revocation trigger.
- Save/update flows should not enqueue direct revocation scheduling for temporary requests.

Testing implications for this feature:

- Do not patch `revoke_special_membership_request.apply_async` to assert ETA scheduling in temporary auto-accept tests.
- Prefer asserting persisted state (`status`, `temporary_expires_at`) and beat-driven revocation behavior.
- Keep generic `apply_async(..., eta=...)` assertions only for features that still use explicit ETA scheduling.

## Email and templates

### Overview
Expand Down Expand Up @@ -79,4 +134,3 @@ send_magic_link_email(
```

The function appends `?token=<token>` to `magic_link_callback_url` and passes the full URL as `magic_link` to the template context.

72 changes: 68 additions & 4 deletions impresso/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django.contrib.auth.models import User
from django.utils.translation import ngettext
from django.utils import timezone

from django.utils.html import format_html
from impresso.models.userBitmapSubscription import UserBitmapSubscription
from .models import Issue, Job, Page, Newspaper
from .models import SearchQuery, ContentItem
Expand Down Expand Up @@ -87,16 +87,23 @@ def set_terms_accepted_date(self, request, queryset):


class SpecialMembershipDatasetAdminForm(forms.ModelForm):

class Meta:
model = SpecialMembershipDataset
fields = "__all__"
help_texts = {
"metadata": format_html(
"<b>Allowed Values</b>: <pre>{}</pre><br><b>Note</b>: revokeAfterDays is working only if enableTemporaryAutomaticAcceptance is set to true.",
", ".join(sorted(SpecialMembershipDataset.METADATA_ALLOWED_KEYS)),
),
}

def clean_metadata(self) -> dict:
metadata = self.cleaned_data.get("metadata") or {}
if not isinstance(metadata, dict):
raise ValidationError("Metadata must be a JSON object.")

allowed_keys = {"modality"}
allowed_keys = SpecialMembershipDataset.METADATA_ALLOWED_KEYS
extra_keys = set(metadata.keys()) - allowed_keys
if extra_keys:
raise ValidationError(
Expand All @@ -115,18 +122,75 @@ def clean_metadata(self) -> dict:
if modality not in allowed_modalities:
allowed = ", ".join(sorted(allowed_modalities))
raise ValidationError(f"metadata.modality must be one of: {allowed}.")

enable_temporary_automatic_acceptance = metadata.get(
"enableTemporaryAutomaticAcceptance"
)
if enable_temporary_automatic_acceptance is not None and not isinstance(
enable_temporary_automatic_acceptance, bool
):
raise ValidationError(
"metadata.enableTemporaryAutomaticAcceptance must be a boolean."
)
revoke_after_days = metadata.get("revokeAfterDays")
if revoke_after_days is not None:
if not isinstance(revoke_after_days, (int, float)):
raise ValidationError(
"metadata.revokeAfterDays must be an integer or float."
)
if revoke_after_days <= 0:
raise ValidationError(
"metadata.revokeAfterDays must be a positive integer or float."
)
return metadata


@admin.register(SpecialMembershipDataset)
class SpecialMembershipDatasetAdmin(ModelAdmin):
list_display = ("title", "bitmap_position", "reviewer", "metadata")
list_display = (
"title",
"bitmap_position",
"reviewer",
"modality",
"enable_temporary_automatic_acceptance",
"revoke_after_days_display",
)
search_fields = ["title", "reviewer__username", "reviewer__email"]
readonly_fields = ("bitmap_position",)
autocomplete_fields = ["reviewer"]
form = SpecialMembershipDatasetAdminForm

@admin.display(description="Revoke after")
def revoke_after_days_display(self, obj: SpecialMembershipDataset) -> str:
days = obj.revoke_after_days
if days is None:
return "-"
total_minutes = round(days * 24 * 60)
parts = []
years, remainder = divmod(total_minutes, 365 * 24 * 60)
if years:
parts.append(f"{years}y")
months, remainder = divmod(remainder, 30 * 24 * 60)
if months:
parts.append(f"{months}mo")
weeks, remainder = divmod(remainder, 7 * 24 * 60)
if weeks:
parts.append(f"{weeks}w")
day_part, remainder = divmod(remainder, 24 * 60)
if day_part:
parts.append(f"{day_part}d")
hours, minutes = divmod(remainder, 60)
if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}m")
return format_html(
(
f"<b>{obj.revoke_after_days}</b> days <br/> " + " ".join(parts)
if parts
else "0m"
),
)


@admin.register(Issue)
class IssueAdmin(ModelAdmin):
Expand Down
62 changes: 62 additions & 0 deletions impresso/management/commands/checktemporarymemberships.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from typing import Any
from django.core.management.base import BaseCommand
from django.utils import timezone
from impresso.models import UserSpecialMembershipRequest
from impresso.tasks.userSpecialMembershipRequest_tasks import (
revoke_expired_temporary_memberships,
)

class Command(BaseCommand):
help = (
"Check temporary special memberships and enqueue asynchronous revocation for "
"expired ones."
)

def add_arguments(self, parser) -> None:
parser.add_argument(
"--dry-run",
action="store_true",
help="Run the command without making any actual changes to the database.",
)

def handle(self, *args: Any, **options: Any) -> None:
dry_run = options.get("dry_run", False)

if dry_run:
self.stdout.write(self.style.NOTICE("Running in DRY RUN mode."))

requests = UserSpecialMembershipRequest.objects.filter(
status=UserSpecialMembershipRequest.STATUS_APPROVED_TEMPORARY
)

count = requests.count()
self.stdout.write(self.style.SUCCESS(f"Found {count} special memberships with temporary approval."))

for req in requests:
expires_at = req.temporary_expires_at
expired_str = ""
if expires_at and expires_at < timezone.now():
expired_str = " (EXPIRED)"
elif expires_at:
expired_str = f" (Expires: {expires_at.strftime('%Y-%m-%d %H:%M:%S %Z')})"

dataset_title = req.subscription.title if req.subscription else "None"
self.stdout.write(
f"- Request ID: {req.pk}, User: {req.user.username}, "
f"Dataset: {dataset_title}{expired_str}"
)

if dry_run:
self.stdout.write(
self.style.NOTICE(
"Dry run completed: task dispatch skipped."
)
)
return

async_result = revoke_expired_temporary_memberships.delay()
self.stdout.write(
self.style.SUCCESS(
f"Enqueued revoke_expired_temporary_memberships task (id={async_result.id})."
)
)
4 changes: 1 addition & 3 deletions impresso/management/commands/createspecialmembership.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@


class Command(BaseCommand):
help = (
"Create a pending special membership request for a specific user and dataset."
)
help = "Create a special membership request for a specific user and dataset."

def add_arguments(self, parser) -> None:
parser.add_argument(
Expand Down
123 changes: 123 additions & 0 deletions impresso/management/commands/createspecialmembershiprequest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
from datetime import timedelta
from typing import Any

from django.contrib.auth.models import User
from django.core.management.base import BaseCommand, CommandError
from django.db import IntegrityError
from django.utils import timezone

from django.conf import settings
from impresso.models import SpecialMembershipDataset, UserSpecialMembershipRequest


class Command(BaseCommand):
help = (
"Create a pending special membership request on behalf of a user "
"using user email and dataset id."
)

def add_arguments(self, parser) -> None:
parser.add_argument(
"user_email",
type=str,
help="Email of the user for whom the request will be created",
)
parser.add_argument(
"dataset_id",
type=int,
help="SpecialMembershipDataset id",
)
parser.add_argument(
"--status",
type=str,
default=UserSpecialMembershipRequest.STATUS_PENDING,
choices=[
UserSpecialMembershipRequest.STATUS_PENDING,
UserSpecialMembershipRequest.STATUS_APPROVED,
UserSpecialMembershipRequest.STATUS_APPROVED_TEMPORARY,
UserSpecialMembershipRequest.STATUS_REJECTED,
UserSpecialMembershipRequest.STATUS_REVOKED,
],
help=(
"Initial status of the request "
f"(default: {UserSpecialMembershipRequest.STATUS_PENDING})"
),
)
parser.add_argument(
"--notes",
type=str,
default=None,
help="Optional notes to attach to the request",
)
parser.add_argument(
"--revoke-after",
type=float,
default=None,
dest="revoke_after",
help="Number of days after which the temporary membership expires (sets temporary_expires_at and overrides the default value from subscription metadata)",
)

def handle(
self, user_email: str, dataset_id: int, *args: Any, **options: Any
) -> None:
try:
user = User.objects.get(email=user_email)
except User.DoesNotExist as exc:
raise CommandError(
f"User with email '{user_email}' does not exist."
) from exc

try:
dataset = SpecialMembershipDataset.objects.select_related("reviewer").get(
pk=dataset_id
)
except SpecialMembershipDataset.DoesNotExist as exc:
raise CommandError(
f"SpecialMembershipDataset with id={dataset_id} does not exist."
) from exc

revoke_after: float | None = options["revoke_after"]
# Check that revoke_after options is valid int or float greater than 0
if revoke_after is not None:
if revoke_after <= 0:
raise CommandError(
f"Invalid value for --revoke-after: {revoke_after}. It must be a positive number."
)

if (
revoke_after is None
and options["status"]
== UserSpecialMembershipRequest.STATUS_APPROVED_TEMPORARY
):
revoke_after = dataset.resolve_revoke_after_days(
default_days=settings.IMPRESSO_SPECIAL_MEMBERSHIP_TEMPORARY_APPROVAL_DEFAULT_DAYS
)

# normally this would be set from request.creation_date + revoke_after,
# but since we're creating the request now we can set it from now + revoke_after.
temporary_expires_at = (
timezone.now() + timedelta(days=revoke_after)
if revoke_after is not None
else None
)

try:
request = UserSpecialMembershipRequest.objects.create(
user=user,
reviewer=dataset.reviewer,
subscription=dataset,
status=options["status"],
notes=options["notes"],
temporary_expires_at=temporary_expires_at,
)
Comment thread
danieleguido marked this conversation as resolved.
except IntegrityError as exc:
raise CommandError(
"A request for this user and dataset already exists or could not be created."
) from exc

self.stdout.write(
self.style.SUCCESS(
"Created special membership request "
f"id={request.pk} for user='{user.email}' and dataset_id={dataset.pk}."
)
)
Loading
Loading