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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,8 @@ services:
- ./trojstenid:/app/trojstenid/
tty: true

redis:
image: redis

volumes:
postgres_data:
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ dependencies = [
"django-ipware~=7.0.1",
"djangorestframework>=3.16.1",
"djangorestframework-stubs>=3.16.6",
"django-rq>=3.2.2",
"environs[django]>=14.5.0",
"requests>=2.32.4",
]

[tool.ruff.lint]
Expand Down
3 changes: 3 additions & 0 deletions trojstenid/schools/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ class SchoolsConfig(AppConfig):
name = "trojstenid.schools"
label = "trojstenid_schools"
verbose_name = "Schools"

def ready(self):
from . import signals # noqa
10 changes: 10 additions & 0 deletions trojstenid/schools/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.db.models.signals import post_save
from django.dispatch import receiver

from trojstenid.schools.models import UserSchoolRecord
from trojstenid.users.tasks import send_user_update


@receiver(post_save, sender=UserSchoolRecord)
def user_school_record_saved(sender, instance: UserSchoolRecord, **kwargs):
send_user_update.delay(instance.user_id)
20 changes: 15 additions & 5 deletions trojstenid/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@

from pathlib import Path

import environ
from environs import Env

import trojstenid

env = environ.Env()
env = Env()
env.read_env()

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
Expand All @@ -29,7 +30,7 @@
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = 3600

ALLOWED_HOSTS = env("ALLOWED_HOSTS", default=[])
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[])

# Application definition

Expand Down Expand Up @@ -59,6 +60,7 @@
"oauth2_provider",
"django_cleanup",
"django_probes",
"django_rq",
]

MIDDLEWARE = [
Expand Down Expand Up @@ -99,7 +101,7 @@
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases

DATABASES = {
"default": env.db(),
"default": env.dj_db_url("DATABASE_URL"),
}

# Password validation
Expand Down Expand Up @@ -186,7 +188,7 @@
},
}

vars().update(env.email(default="consolemail://"))
vars().update(env.dj_email_url("EMAIL_URL", default="consolemail://"))
DEFAULT_FROM_EMAIL = env("EMAIL_FROM", default="root@localhost")

# Internationalization
Expand Down Expand Up @@ -248,6 +250,14 @@
},
}

RQ_QUEUES = {
"default": {
"HOST": env("REDIS_HOST", default="redis"),
"PORT": 6379,
"ASYNC": not DEBUG,
},
}


if DEBUG:
import socket # only if you haven't already imported this
Expand Down
1 change: 1 addition & 0 deletions trojstenid/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
path("accounts/", include("allauth.urls")),
path("oauth/", include("trojstenid.users.urls_oauth", namespace="oauth2_provider")),
path("api/", include("trojstenid.users.urls_api", namespace="api")),
path("django-rq/", include("django_rq.urls")),
]

if settings.DEBUG:
Expand Down
17 changes: 17 additions & 0 deletions trojstenid/users/migrations/0008_application_push_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.2.4 on 2026-01-30 13:56

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("users", "0007_application_group"),
]

operations = [
migrations.AddField(
model_name="application",
name="push_urls",
field=models.TextField(blank=True),
),
]
1 change: 1 addition & 0 deletions trojstenid/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

class Application(AbstractApplication):
group = models.ForeignKey(Group, on_delete=models.RESTRICT, blank=True, null=True)
push_urls = models.TextField(blank=True)


def user_avatar_name(user, filename):
Expand Down
19 changes: 19 additions & 0 deletions trojstenid/users/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
from django.contrib.auth.signals import (
user_logged_out as dj_user_logged_out,
)
from django.db.models.signals import m2m_changed, post_save
from django.dispatch import receiver
from oauth2_provider.signals import app_authorized

from trojstenid import audit
from trojstenid.users.models import User
from trojstenid.users.tasks import send_user_update

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -56,3 +58,20 @@ def log_app_authorization(sender, request, token, **kwargs):
f"application {token.application.name} was authorized for "
f"user {token.user.username}",
)


@receiver(post_save, sender=User)
def user_saved(sender, instance: User, **kwargs):
send_user_update.delay(instance.id)


@receiver(post_save, sender=EmailAddress)
def emailaddress_saved(sender, instance: EmailAddress, **kwargs):
send_user_update.delay(instance.user_id) # type:ignore


@receiver(m2m_changed, sender=User.groups.through)
def groups_changed(sender, instance, **kwargs):
if not isinstance(instance, User):
return
send_user_update.delay(instance.id)
35 changes: 35 additions & 0 deletions trojstenid/users/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import logging

import requests
from django_rq import job

from trojstenid.users.models import Application, User
from trojstenid.users.serializers import UserSerializer

logger = logging.getLogger(__name__)


@job
def send_user_update(user_id: int):
logger.info(f"pushing user update (uid {user_id})")
user = User.objects.get(id=user_id)
user_json = UserSerializer(user).data

applications = Application.objects.exclude(push_urls="")
for app in applications:
urls = app.push_urls.split()
for url in urls:
try:
requests.post(
url,
json=user_json,
timeout=15,
headers={
"X-Client-ID": app.client_id,
"X-Client-Secret": app.client_secret,
},
)
except Exception as e:
# No special error handling, as we treat this channel as "best-effort".
# Apps can always request fresh data through API or OIDC flow.
logger.error(f"error while pushing user update to {url}: {e}")
Loading