stapel-profiles 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stapel_profiles-0.3.1/LICENSE +21 -0
- stapel_profiles-0.3.1/PKG-INFO +57 -0
- stapel_profiles-0.3.1/README.md +37 -0
- stapel_profiles-0.3.1/__init__.py +40 -0
- stapel_profiles-0.3.1/actions.py +23 -0
- stapel_profiles-0.3.1/admin.py +38 -0
- stapel_profiles-0.3.1/apps.py +17 -0
- stapel_profiles-0.3.1/conf.py +26 -0
- stapel_profiles-0.3.1/conftest.py +81 -0
- stapel_profiles-0.3.1/dto.py +192 -0
- stapel_profiles-0.3.1/errors.py +34 -0
- stapel_profiles-0.3.1/events.py +36 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_cn.svg +15 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_cs.svg +12 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_de.svg +12 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_en.svg +23 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_es.svg +11 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_fr.svg +12 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_hr.svg +237 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_hu.svg +12 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_in.svg +63 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_it.svg +12 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_jp.svg +11 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_kr.svg +24 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_lu.svg +12 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_nl.svg +12 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_pl.svg +11 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_pt.svg +15 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_rs.svg +290 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_ru.svg +17 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_sv.svg +12 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_tr.svg +13 -0
- stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_ua.svg +11 -0
- stapel_profiles-0.3.1/fixtures/languages.json +35 -0
- stapel_profiles-0.3.1/gdpr.py +55 -0
- stapel_profiles-0.3.1/management/__init__.py +0 -0
- stapel_profiles-0.3.1/management/commands/__init__.py +0 -0
- stapel_profiles-0.3.1/management/commands/publish_all_profiles.py +60 -0
- stapel_profiles-0.3.1/management/commands/sync_languages.py +82 -0
- stapel_profiles-0.3.1/migrations/0001_initial.py +62 -0
- stapel_profiles-0.3.1/migrations/0002_add_avatar_field.py +19 -0
- stapel_profiles-0.3.1/migrations/0003_add_display_name.py +18 -0
- stapel_profiles-0.3.1/migrations/0004_add_settings_fields.py +28 -0
- stapel_profiles-0.3.1/migrations/0005_add_location_id.py +18 -0
- stapel_profiles-0.3.1/migrations/0006_add_location_display_names.py +31 -0
- stapel_profiles-0.3.1/migrations/0007_add_initial_setup_passed.py +18 -0
- stapel_profiles-0.3.1/migrations/0008_add_language_is_active.py +18 -0
- stapel_profiles-0.3.1/migrations/0009_alter_display_name_max_length.py +18 -0
- stapel_profiles-0.3.1/migrations/0010_add_notification_prefs.py +33 -0
- stapel_profiles-0.3.1/migrations/0011_add_auto_detected_language.py +23 -0
- stapel_profiles-0.3.1/migrations/0012_add_rating_fields.py +27 -0
- stapel_profiles-0.3.1/migrations/0013_drop_orphan_rating_fields.py +15 -0
- stapel_profiles-0.3.1/migrations/__init__.py +0 -0
- stapel_profiles-0.3.1/models.py +229 -0
- stapel_profiles-0.3.1/py.typed +0 -0
- stapel_profiles-0.3.1/pyproject.toml +51 -0
- stapel_profiles-0.3.1/schemas/consumes/user.deleted.json +13 -0
- stapel_profiles-0.3.1/schemas/consumes/user.deletion_initiated.json +13 -0
- stapel_profiles-0.3.1/schemas/emits/profile.changed.json +35 -0
- stapel_profiles-0.3.1/serializers.py +419 -0
- stapel_profiles-0.3.1/setup.cfg +4 -0
- stapel_profiles-0.3.1/stapel_profiles.egg-info/PKG-INFO +57 -0
- stapel_profiles-0.3.1/stapel_profiles.egg-info/SOURCES.txt +148 -0
- stapel_profiles-0.3.1/stapel_profiles.egg-info/dependency_links.txt +1 -0
- stapel_profiles-0.3.1/stapel_profiles.egg-info/requires.txt +7 -0
- stapel_profiles-0.3.1/stapel_profiles.egg-info/top_level.txt +1 -0
- stapel_profiles-0.3.1/tests/__init__.py +0 -0
- stapel_profiles-0.3.1/tests/test_api_profiles.py +147 -0
- stapel_profiles-0.3.1/tests/test_api_relationships.py +195 -0
- stapel_profiles-0.3.1/tests/test_api_unsubscribe.py +76 -0
- stapel_profiles-0.3.1/tests/test_avatar_validation.py +109 -0
- stapel_profiles-0.3.1/tests/test_events.py +89 -0
- stapel_profiles-0.3.1/tests/test_gdpr_actions.py +153 -0
- stapel_profiles-0.3.1/tests/test_management_commands.py +66 -0
- stapel_profiles-0.3.1/tests/test_models.py +155 -0
- stapel_profiles-0.3.1/tests/test_public_api.py +71 -0
- stapel_profiles-0.3.1/tests/test_serializers_extra.py +118 -0
- stapel_profiles-0.3.1/urls.py +45 -0
- stapel_profiles-0.3.1/validators.py +76 -0
- stapel_profiles-0.3.1/views.py +641 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 usestapel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stapel-profiles
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: User profiles Django app for the Stapel framework
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: django,stapel,profiles,users,social
|
|
7
|
+
Classifier: Framework :: Django
|
|
8
|
+
Classifier: Framework :: Django :: 5.2
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: stapel-core<0.4,>=0.3.0
|
|
15
|
+
Provides-Extra: kafka
|
|
16
|
+
Requires-Dist: confluent-kafka>=2.3; extra == "kafka"
|
|
17
|
+
Provides-Extra: all
|
|
18
|
+
Requires-Dist: stapel-profiles[kafka]; extra == "all"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# stapel-profiles
|
|
22
|
+
|
|
23
|
+
[](https://github.com/usestapel/stapel-profiles/actions/workflows/ci.yml)
|
|
24
|
+
[](https://codecov.io/gh/usestapel/stapel-profiles)
|
|
25
|
+
|
|
26
|
+
> User profiles — avatars, social graph (follow/block), privacy settings, language preferences
|
|
27
|
+
|
|
28
|
+
Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install stapel-profiles
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
# settings.py
|
|
40
|
+
INSTALLED_APPS = [
|
|
41
|
+
...
|
|
42
|
+
'stapel_profiles',
|
|
43
|
+
]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Bus events
|
|
47
|
+
|
|
48
|
+
### Emits
|
|
49
|
+
| `profile.updated` | [schema](schemas/emits/profile.updated.json) | User profile fields were updated. |
|
|
50
|
+
|
|
51
|
+
### Consumes
|
|
52
|
+
| `user.deleted` | [schema](schemas/consumes/user.deleted.json) |
|
|
53
|
+
| `user.deletion_initiated` | [schema](schemas/consumes/user.deletion_initiated.json) |
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# stapel-profiles
|
|
2
|
+
|
|
3
|
+
[](https://github.com/usestapel/stapel-profiles/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/usestapel/stapel-profiles)
|
|
5
|
+
|
|
6
|
+
> User profiles — avatars, social graph (follow/block), privacy settings, language preferences
|
|
7
|
+
|
|
8
|
+
Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install stapel-profiles
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
# settings.py
|
|
20
|
+
INSTALLED_APPS = [
|
|
21
|
+
...
|
|
22
|
+
'stapel_profiles',
|
|
23
|
+
]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Bus events
|
|
27
|
+
|
|
28
|
+
### Emits
|
|
29
|
+
| `profile.updated` | [schema](schemas/emits/profile.updated.json) | User profile fields were updated. |
|
|
30
|
+
|
|
31
|
+
### Consumes
|
|
32
|
+
| `user.deleted` | [schema](schemas/consumes/user.deleted.json) |
|
|
33
|
+
| `user.deletion_initiated` | [schema](schemas/consumes/user.deletion_initiated.json) |
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
MIT — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Stapel Profiles — user profiles Django app for the Stapel framework.
|
|
2
|
+
|
|
3
|
+
Public API (see ``__all__``):
|
|
4
|
+
|
|
5
|
+
- ``profiles_settings`` — package settings object (``PROFILES_*`` keys,
|
|
6
|
+
resolved via ``STAPEL_PROFILES`` / flat settings / env vars).
|
|
7
|
+
- ``publish_profile_changed`` — emit the ``profile.changed`` comm action
|
|
8
|
+
for a mutated profile.
|
|
9
|
+
- ``validate_display_name`` — display-name validation helper (raises
|
|
10
|
+
``StapelValidationError``).
|
|
11
|
+
- ``ProfilesGDPRProvider`` — GDPR export/delete provider for profile data.
|
|
12
|
+
|
|
13
|
+
Signal usage (``profile_updated``) stays in ``stapel_core.signals``.
|
|
14
|
+
|
|
15
|
+
All exports are lazily imported (PEP 562): importing ``stapel_profiles``
|
|
16
|
+
itself does not require Django to be configured.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
_EXPORTS = {
|
|
20
|
+
"profiles_settings": ".conf",
|
|
21
|
+
"publish_profile_changed": ".events",
|
|
22
|
+
"validate_display_name": ".validators",
|
|
23
|
+
"ProfilesGDPRProvider": ".gdpr",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
__all__ = list(_EXPORTS)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def __getattr__(name):
|
|
30
|
+
if name in _EXPORTS:
|
|
31
|
+
import importlib
|
|
32
|
+
|
|
33
|
+
value = getattr(importlib.import_module(_EXPORTS[name], __name__), name)
|
|
34
|
+
globals()[name] = value # cache for subsequent lookups
|
|
35
|
+
return value
|
|
36
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def __dir__():
|
|
40
|
+
return sorted(set(globals()) | set(__all__))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Action subscriptions of the profiles module.
|
|
2
|
+
|
|
3
|
+
Handlers must be idempotent: delivery is at-least-once (outbox retries,
|
|
4
|
+
broker redelivery).
|
|
5
|
+
"""
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from stapel_core.comm import on_action
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@on_action("user.deleted")
|
|
14
|
+
def handle_user_deleted(event):
|
|
15
|
+
"""Erase profile PII when an account deletion is executed (GDPR Art. 17)."""
|
|
16
|
+
from .gdpr import ProfilesGDPRProvider
|
|
17
|
+
|
|
18
|
+
user_id = event.payload.get("user_id")
|
|
19
|
+
if not user_id:
|
|
20
|
+
logger.error("user.deleted event without user_id: %s", event.event_id)
|
|
21
|
+
return
|
|
22
|
+
ProfilesGDPRProvider().delete(user_id)
|
|
23
|
+
logger.info("profiles erased for deleted user %s", user_id)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Admin configuration for profiles app.
|
|
3
|
+
"""
|
|
4
|
+
from django.contrib import admin
|
|
5
|
+
from .models import Language, Profile, UserRelationship
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@admin.register(Language)
|
|
9
|
+
class LanguageAdmin(admin.ModelAdmin):
|
|
10
|
+
"""Admin for Language model."""
|
|
11
|
+
list_display = ['code', 'name', 'flag', 'is_active']
|
|
12
|
+
list_filter = ['is_active']
|
|
13
|
+
search_fields = ['code', 'name']
|
|
14
|
+
ordering = ['name']
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@admin.register(Profile)
|
|
18
|
+
class ProfileAdmin(admin.ModelAdmin):
|
|
19
|
+
"""Admin for Profile model."""
|
|
20
|
+
list_display = [
|
|
21
|
+
'user_id', 'currency_code', 'measurement_units',
|
|
22
|
+
'theme', 'app_language', 'created_at'
|
|
23
|
+
]
|
|
24
|
+
list_filter = ['currency_code', 'measurement_units', 'theme', 'app_language']
|
|
25
|
+
search_fields = ['user_id']
|
|
26
|
+
filter_horizontal = ['understands']
|
|
27
|
+
readonly_fields = ['created_at', 'updated_at']
|
|
28
|
+
ordering = ['-created_at']
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@admin.register(UserRelationship)
|
|
32
|
+
class UserRelationshipAdmin(admin.ModelAdmin):
|
|
33
|
+
"""Admin for UserRelationship model."""
|
|
34
|
+
list_display = ['follower_id', 'following_id', 'status', 'created_at']
|
|
35
|
+
list_filter = ['status']
|
|
36
|
+
search_fields = ['follower_id', 'following_id']
|
|
37
|
+
readonly_fields = ['created_at', 'updated_at']
|
|
38
|
+
ordering = ['-created_at']
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ProfilesConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "stapel_profiles"
|
|
7
|
+
label = 'profiles'
|
|
8
|
+
verbose_name = "Stapel Profiles"
|
|
9
|
+
|
|
10
|
+
def ready(self):
|
|
11
|
+
from stapel_core.gdpr import gdpr_registry
|
|
12
|
+
from .gdpr import ProfilesGDPRProvider
|
|
13
|
+
gdpr_registry.register(ProfilesGDPRProvider())
|
|
14
|
+
|
|
15
|
+
# Action subscriptions (in-process in a monolith, bus consumer in
|
|
16
|
+
# microservices — same code, transport chosen by STAPEL_COMM).
|
|
17
|
+
from . import actions # noqa: F401
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Settings for stapel-profiles.
|
|
2
|
+
|
|
3
|
+
Resolution order per key (see stapel_core.conf.AppSettings):
|
|
4
|
+
settings.STAPEL_PROFILES dict -> flat Django setting of the same name ->
|
|
5
|
+
environment variable -> default.
|
|
6
|
+
|
|
7
|
+
Keys are intentionally prefixed (``PROFILES_...``) so the flat Django
|
|
8
|
+
setting / env var form is unambiguous:
|
|
9
|
+
|
|
10
|
+
# settings.py — either form works
|
|
11
|
+
PROFILES_AVATAR_CHECK = "http"
|
|
12
|
+
STAPEL_PROFILES = {"PROFILES_AVATAR_CHECK": "off"}
|
|
13
|
+
|
|
14
|
+
PROFILES_AVATAR_CHECK — how validate_avatar verifies the CDN reference:
|
|
15
|
+
"comm" (default) — stapel_core.comm.call("cdn.media_exists", ...)
|
|
16
|
+
"http" — legacy direct HTTP via check_cdn_media_exists
|
|
17
|
+
"off" — skip the existence check (format still validated)
|
|
18
|
+
"""
|
|
19
|
+
from stapel_core.conf import AppSettings
|
|
20
|
+
|
|
21
|
+
profiles_settings = AppSettings(
|
|
22
|
+
"STAPEL_PROFILES",
|
|
23
|
+
defaults={
|
|
24
|
+
"PROFILES_AVATAR_CHECK": "comm",
|
|
25
|
+
},
|
|
26
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def pytest_configure(config):
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
if not settings.configured:
|
|
9
|
+
settings.configure(
|
|
10
|
+
SECRET_KEY="test-secret-key-not-for-production",
|
|
11
|
+
INSTALLED_APPS=[
|
|
12
|
+
"django.contrib.contenttypes",
|
|
13
|
+
"django.contrib.auth",
|
|
14
|
+
"django.contrib.sessions",
|
|
15
|
+
"django.contrib.messages",
|
|
16
|
+
"django.contrib.admin",
|
|
17
|
+
"stapel_core.django.users",
|
|
18
|
+
"rest_framework",
|
|
19
|
+
"stapel_profiles",
|
|
20
|
+
],
|
|
21
|
+
AUTH_USER_MODEL="users.User",
|
|
22
|
+
DATABASES={
|
|
23
|
+
"default": {
|
|
24
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
25
|
+
"NAME": ":memory:",
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
|
|
29
|
+
USE_TZ=True,
|
|
30
|
+
ROOT_URLCONF="stapel_profiles.urls",
|
|
31
|
+
CACHES={
|
|
32
|
+
"default": {
|
|
33
|
+
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
# In-memory bus — no Kafka/Redis broker needed
|
|
37
|
+
STAPEL_BUS_BACKEND="stapel_core.bus.backends.memory.MemoryBus",
|
|
38
|
+
# Synchronous in-process comm delivery — tests assert emitted
|
|
39
|
+
# actions directly, no outbox table / relay involved.
|
|
40
|
+
STAPEL_COMM={"OUTBOX_ENABLED": False},
|
|
41
|
+
# Skip migrations — create tables directly from models
|
|
42
|
+
MIGRATION_MODULES={
|
|
43
|
+
"users": None,
|
|
44
|
+
"profiles": None,
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture
|
|
50
|
+
def api_client():
|
|
51
|
+
from rest_framework.test import APIClient
|
|
52
|
+
|
|
53
|
+
return APIClient()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.fixture
|
|
57
|
+
def user(db):
|
|
58
|
+
from stapel_core.django.users.models import User
|
|
59
|
+
|
|
60
|
+
return User.objects.create_user(
|
|
61
|
+
username=f"u-{uuid.uuid4().hex[:8]}",
|
|
62
|
+
email=f"{uuid.uuid4().hex[:8]}@example.com",
|
|
63
|
+
password="testpass-1234",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture
|
|
68
|
+
def other_user(db):
|
|
69
|
+
from stapel_core.django.users.models import User
|
|
70
|
+
|
|
71
|
+
return User.objects.create_user(
|
|
72
|
+
username=f"u-{uuid.uuid4().hex[:8]}",
|
|
73
|
+
email=f"{uuid.uuid4().hex[:8]}@example.com",
|
|
74
|
+
password="testpass-1234",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.fixture
|
|
79
|
+
def authed_client(api_client, user):
|
|
80
|
+
api_client.force_authenticate(user=user)
|
|
81
|
+
return api_client
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Data Transfer Objects for profiles API."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional, List
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class LanguageResponse:
|
|
9
|
+
"""Language info.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
code: ISO 639-1 language code. Example: en
|
|
13
|
+
name: Human-readable language name. Example: English
|
|
14
|
+
flag: URL to flag image. Example: /flags/en.svg
|
|
15
|
+
"""
|
|
16
|
+
code: str
|
|
17
|
+
name: str
|
|
18
|
+
flag: Optional[str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ProfileResponse:
|
|
23
|
+
"""Full user profile (for /me endpoint).
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
user_id: User UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
|
27
|
+
display_name: Display name. Example: John Doe
|
|
28
|
+
avatar: CDN avatar reference. Example: avatar/abc123
|
|
29
|
+
location_id: Geo service location ID. Example: 42
|
|
30
|
+
location_display_name_narrow: Short location. Example: LU - Mamer
|
|
31
|
+
location_display_name_broad: Broad location. Example: LU - Differdange
|
|
32
|
+
currency_code: ISO 4217 currency code. Example: EUR
|
|
33
|
+
measurement_units: Measurement system. Example: metric
|
|
34
|
+
theme: UI theme. Example: system
|
|
35
|
+
app_language: Selected app language.
|
|
36
|
+
understands: List of language codes the user understands. Example: ["en", "fr"]
|
|
37
|
+
use_device_language: Use device language for UI. Example: true
|
|
38
|
+
auto_detected_language: Last detected language from Accept-Language header. Example: de
|
|
39
|
+
auto_translate_content: Auto-translate ad content. Example: false
|
|
40
|
+
email_messages: Receive message notifications via email. Example: true
|
|
41
|
+
email_system: Receive system notifications via email. Example: true
|
|
42
|
+
push_messages: Receive message notifications via push. Example: true
|
|
43
|
+
push_system: Receive system notifications via push. Example: true
|
|
44
|
+
essential_cookies_accepted: Essential cookies consent. Example: true
|
|
45
|
+
initial_setup_passed: Whether onboarding is complete. Example: true
|
|
46
|
+
followers_count: Number of followers. Example: 42
|
|
47
|
+
following_count: Number of users followed. Example: 15
|
|
48
|
+
rating: User rating. Example: 4.8
|
|
49
|
+
created_at: ISO 8601 creation time. Example: 2025-01-15T12:00:00Z
|
|
50
|
+
updated_at: ISO 8601 last update time. Example: 2025-01-20T14:30:00Z
|
|
51
|
+
"""
|
|
52
|
+
user_id: UUID
|
|
53
|
+
display_name: str
|
|
54
|
+
avatar: Optional[str]
|
|
55
|
+
location_id: Optional[int]
|
|
56
|
+
location_display_name_narrow: str
|
|
57
|
+
location_display_name_broad: str
|
|
58
|
+
currency_code: str
|
|
59
|
+
measurement_units: str
|
|
60
|
+
theme: str
|
|
61
|
+
app_language: Optional[LanguageResponse]
|
|
62
|
+
understands: List[str]
|
|
63
|
+
use_device_language: bool
|
|
64
|
+
auto_detected_language: str
|
|
65
|
+
auto_translate_content: bool
|
|
66
|
+
email_messages: bool
|
|
67
|
+
email_system: bool
|
|
68
|
+
push_messages: bool
|
|
69
|
+
push_system: bool
|
|
70
|
+
essential_cookies_accepted: bool
|
|
71
|
+
initial_setup_passed: bool
|
|
72
|
+
followers_count: int
|
|
73
|
+
following_count: int
|
|
74
|
+
rating: float
|
|
75
|
+
created_at: str
|
|
76
|
+
updated_at: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class ProfilePublicResponse:
|
|
81
|
+
"""Public profile for viewing other users.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
user_id: User UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
|
85
|
+
display_name: Display name. Example: Jane Smith
|
|
86
|
+
avatar: CDN avatar reference. Example: avatar/def456
|
|
87
|
+
location_id: Geo service location ID. Example: 42
|
|
88
|
+
location_display_name_narrow: Short location. Example: LU - Mamer
|
|
89
|
+
location_display_name_broad: Broad location. Example: LU - Differdange
|
|
90
|
+
followers_count: Number of followers. Example: 120
|
|
91
|
+
following_count: Number of users followed. Example: 30
|
|
92
|
+
rating: User rating. Example: 4.5
|
|
93
|
+
relationship_status: Relationship to current user. Example: following
|
|
94
|
+
"""
|
|
95
|
+
user_id: UUID
|
|
96
|
+
display_name: str
|
|
97
|
+
avatar: Optional[str]
|
|
98
|
+
location_id: Optional[int]
|
|
99
|
+
location_display_name_narrow: str
|
|
100
|
+
location_display_name_broad: str
|
|
101
|
+
followers_count: int
|
|
102
|
+
following_count: int
|
|
103
|
+
rating: float
|
|
104
|
+
relationship_status: Optional[str]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class ProfileUpdateRequest:
|
|
109
|
+
"""Update profile fields (PATCH, all optional).
|
|
110
|
+
|
|
111
|
+
Attributes:
|
|
112
|
+
display_name: New display name. Example: John Doe
|
|
113
|
+
avatar: CDN avatar reference. Example: avatar/abc123
|
|
114
|
+
location_id: Geo service location ID. Example: 42
|
|
115
|
+
currency_code: ISO 4217 currency code. Example: EUR
|
|
116
|
+
measurement_units: Measurement system (metric or imperial). Example: metric
|
|
117
|
+
theme: UI theme (light, dark, system). Example: dark
|
|
118
|
+
app_language: ISO 639-1 language code. Example: en
|
|
119
|
+
understands: List of understood language codes. Example: ["en", "fr"]
|
|
120
|
+
use_device_language: Use device language for UI. Example: true
|
|
121
|
+
auto_translate_content: Auto-translate ad content. Example: false
|
|
122
|
+
email_messages: Toggle message email notifications. Example: true
|
|
123
|
+
email_system: Toggle system email notifications. Example: true
|
|
124
|
+
push_messages: Toggle message push notifications. Example: true
|
|
125
|
+
push_system: Toggle system push notifications. Example: true
|
|
126
|
+
essential_cookies_accepted: Essential cookies consent. Example: true
|
|
127
|
+
initial_setup_passed: Mark onboarding as complete. Example: true
|
|
128
|
+
"""
|
|
129
|
+
display_name: Optional[str] = None
|
|
130
|
+
avatar: Optional[str] = None
|
|
131
|
+
location_id: Optional[int] = None
|
|
132
|
+
currency_code: Optional[str] = None
|
|
133
|
+
measurement_units: Optional[str] = None
|
|
134
|
+
theme: Optional[str] = None
|
|
135
|
+
app_language: Optional[str] = None
|
|
136
|
+
understands: Optional[List[str]] = None
|
|
137
|
+
use_device_language: Optional[bool] = None
|
|
138
|
+
auto_translate_content: Optional[bool] = None
|
|
139
|
+
email_messages: Optional[bool] = None
|
|
140
|
+
email_system: Optional[bool] = None
|
|
141
|
+
push_messages: Optional[bool] = None
|
|
142
|
+
push_system: Optional[bool] = None
|
|
143
|
+
essential_cookies_accepted: Optional[bool] = None
|
|
144
|
+
initial_setup_passed: Optional[bool] = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class RelationshipResponse:
|
|
149
|
+
"""User relationship status.
|
|
150
|
+
|
|
151
|
+
Attributes:
|
|
152
|
+
user_id: Target user UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
|
153
|
+
status: Relationship status. Example: following
|
|
154
|
+
"""
|
|
155
|
+
user_id: UUID
|
|
156
|
+
status: str
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class RelationshipActionResponse:
|
|
161
|
+
"""Result of a relationship action.
|
|
162
|
+
|
|
163
|
+
Attributes:
|
|
164
|
+
success: Whether the action succeeded. Example: true
|
|
165
|
+
status: New relationship status. Example: following
|
|
166
|
+
"""
|
|
167
|
+
success: bool
|
|
168
|
+
status: str
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class FollowersResponse:
|
|
173
|
+
"""User's followers list.
|
|
174
|
+
|
|
175
|
+
Attributes:
|
|
176
|
+
followers: List of follower user UUIDs. Example: ["550e8400-e29b-41d4-a716-446655440000"]
|
|
177
|
+
count: Total follower count. Example: 42
|
|
178
|
+
"""
|
|
179
|
+
followers: List[UUID]
|
|
180
|
+
count: int
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class FollowingResponse:
|
|
185
|
+
"""Users followed by this user.
|
|
186
|
+
|
|
187
|
+
Attributes:
|
|
188
|
+
following: List of followed user UUIDs. Example: ["550e8400-e29b-41d4-a716-446655440000"]
|
|
189
|
+
count: Total following count. Example: 15
|
|
190
|
+
"""
|
|
191
|
+
following: List[UUID]
|
|
192
|
+
count: int
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Custom error keys for the profiles service."""
|
|
2
|
+
|
|
3
|
+
from stapel_core.django.api.errors import ErrorKeysView, register_service_errors
|
|
4
|
+
|
|
5
|
+
ERR_404_PROFILE_NOT_FOUND = 'error.404.profile_not_found'
|
|
6
|
+
ERR_400_CANNOT_FOLLOW_SELF = 'error.400.cannot_follow_self'
|
|
7
|
+
ERR_400_CANNOT_BLOCK_SELF = 'error.400.cannot_block_self'
|
|
8
|
+
ERR_400_DISPLAY_NAME_TOO_SHORT = 'error.400.display_name_too_short'
|
|
9
|
+
ERR_400_DISPLAY_NAME_FORBIDDEN_CHARS = 'error.400.display_name_forbidden_chars'
|
|
10
|
+
ERR_400_DISPLAY_NAME_EMOJI = 'error.400.display_name_emoji'
|
|
11
|
+
ERR_400_DISPLAY_NAME_INVISIBLE_CHARS = 'error.400.display_name_invisible_chars'
|
|
12
|
+
ERR_400_INVALID_CURRENCY = 'error.400.invalid_currency'
|
|
13
|
+
ERR_400_INVALID_AVATAR_FORMAT = 'error.400.invalid_avatar_format'
|
|
14
|
+
ERR_400_AVATAR_NOT_FOUND = 'error.400.avatar_not_found'
|
|
15
|
+
|
|
16
|
+
PROFILES_ERRORS = {
|
|
17
|
+
ERR_404_PROFILE_NOT_FOUND: 'Profile not found',
|
|
18
|
+
ERR_400_CANNOT_FOLLOW_SELF: 'Cannot follow yourself',
|
|
19
|
+
ERR_400_CANNOT_BLOCK_SELF: 'Cannot block yourself',
|
|
20
|
+
ERR_400_DISPLAY_NAME_TOO_SHORT: 'Display name must be at least 2 characters',
|
|
21
|
+
ERR_400_DISPLAY_NAME_FORBIDDEN_CHARS: 'Display name contains forbidden characters',
|
|
22
|
+
ERR_400_DISPLAY_NAME_EMOJI: 'Display name cannot contain emoji',
|
|
23
|
+
ERR_400_DISPLAY_NAME_INVISIBLE_CHARS: 'Display name contains invisible characters',
|
|
24
|
+
ERR_400_INVALID_CURRENCY: 'Invalid currency code',
|
|
25
|
+
ERR_400_INVALID_AVATAR_FORMAT: 'Invalid avatar reference format. Expected: avatar/<hash>',
|
|
26
|
+
ERR_400_AVATAR_NOT_FOUND: 'Avatar not found on CDN',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
register_service_errors(PROFILES_ERRORS)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ProfilesErrorKeysView(ErrorKeysView):
|
|
33
|
+
def get_service_errors(self):
|
|
34
|
+
return PROFILES_ERRORS
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Comm event publishers for the profiles module.
|
|
2
|
+
|
|
3
|
+
Events go through stapel_core.comm (transport is deployment configuration:
|
|
4
|
+
in-process in a monolith, bus in microservices). Payload contract lives in
|
|
5
|
+
schemas/emits/profile.changed.json.
|
|
6
|
+
"""
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def publish_profile_changed(instance):
|
|
13
|
+
"""Emit the ``profile.changed`` action for a mutated profile."""
|
|
14
|
+
try:
|
|
15
|
+
from stapel_core.comm import emit
|
|
16
|
+
|
|
17
|
+
emit(
|
|
18
|
+
"profile.changed",
|
|
19
|
+
{
|
|
20
|
+
"user_id": str(instance.user_id),
|
|
21
|
+
"display_name": instance.display_name,
|
|
22
|
+
"avatar": instance.avatar or "",
|
|
23
|
+
"location_id": instance.location_id,
|
|
24
|
+
"location_display_name_narrow": instance.location_display_name_narrow,
|
|
25
|
+
"location_display_name_broad": instance.location_display_name_broad,
|
|
26
|
+
"app_language": instance.app_language_id if instance.app_language_id else None,
|
|
27
|
+
"auto_detected_language": instance.auto_detected_language or None,
|
|
28
|
+
"email_messages": instance.email_messages,
|
|
29
|
+
"email_system": instance.email_system,
|
|
30
|
+
"push_messages": instance.push_messages,
|
|
31
|
+
"push_system": instance.push_system,
|
|
32
|
+
},
|
|
33
|
+
key=str(instance.user_id),
|
|
34
|
+
)
|
|
35
|
+
except Exception:
|
|
36
|
+
logger.exception("Failed to publish profile-changed event")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<g clip-path="url(#clip0_1811_3547)">
|
|
3
|
+
<path d="M0 0H32V32H0V0Z" fill="#E1261C"/>
|
|
4
|
+
<path d="M8.49521 6.68188L11.9707 17.3744L2.8682 10.7662H14.1222L5.0197 17.3744L8.49521 6.68188Z" fill="#FFFF00"/>
|
|
5
|
+
<path d="M19.1173 4.86824L18.7887 8.60153L16.8584 5.38715L20.3106 6.85509L16.6564 7.69487L19.1173 4.86824Z" fill="#FFFF00"/>
|
|
6
|
+
<path d="M23.7012 9.25131L21.9496 12.5645L21.418 8.85298L24.03 11.5455L20.3363 10.9015L23.7012 9.25131Z" fill="#FFFF00"/>
|
|
7
|
+
<path d="M24.1379 15.8586L21.1871 18.1691L22.2158 14.5635L23.5022 18.0874L20.3926 15.9926L24.1379 15.8586Z" fill="#FFFF00"/>
|
|
8
|
+
<path d="M19.0418 18.6308L18.8683 22.3745L16.8064 19.2429L20.3165 20.5664L16.7003 21.557L19.0418 18.6308Z" fill="#FFFF00"/>
|
|
9
|
+
</g>
|
|
10
|
+
<defs>
|
|
11
|
+
<clipPath id="clip0_1811_3547">
|
|
12
|
+
<rect width="32" height="32" fill="white"/>
|
|
13
|
+
</clipPath>
|
|
14
|
+
</defs>
|
|
15
|
+
</svg>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<g clip-path="url(#clip0_14221_14385)">
|
|
3
|
+
<path d="M24 0H0V24H24V0Z" fill="#D7141A"/>
|
|
4
|
+
<path d="M24 0H0V12H24V0Z" fill="#F3F3F3"/>
|
|
5
|
+
<path d="M12 12L0 0V24L12 12Z" fill="#11457E"/>
|
|
6
|
+
</g>
|
|
7
|
+
<defs>
|
|
8
|
+
<clipPath id="clip0_14221_14385">
|
|
9
|
+
<rect width="24" height="24" rx="8" fill="white"/>
|
|
10
|
+
</clipPath>
|
|
11
|
+
</defs>
|
|
12
|
+
</svg>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<g clip-path="url(#clip0_1284_1701)">
|
|
3
|
+
<path d="M0 0H32V32H0V0Z" fill="black"/>
|
|
4
|
+
<path d="M0 10.6667H32V32H0V10.6667Z" fill="#DD0000"/>
|
|
5
|
+
<path d="M0 21.3333H32V32H0V21.3333Z" fill="#FFCE00"/>
|
|
6
|
+
</g>
|
|
7
|
+
<defs>
|
|
8
|
+
<clipPath id="clip0_1284_1701">
|
|
9
|
+
<rect width="32" height="32" rx="8" fill="white"/>
|
|
10
|
+
</clipPath>
|
|
11
|
+
</defs>
|
|
12
|
+
</svg>
|