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.
Files changed (80) hide show
  1. stapel_profiles-0.3.1/LICENSE +21 -0
  2. stapel_profiles-0.3.1/PKG-INFO +57 -0
  3. stapel_profiles-0.3.1/README.md +37 -0
  4. stapel_profiles-0.3.1/__init__.py +40 -0
  5. stapel_profiles-0.3.1/actions.py +23 -0
  6. stapel_profiles-0.3.1/admin.py +38 -0
  7. stapel_profiles-0.3.1/apps.py +17 -0
  8. stapel_profiles-0.3.1/conf.py +26 -0
  9. stapel_profiles-0.3.1/conftest.py +81 -0
  10. stapel_profiles-0.3.1/dto.py +192 -0
  11. stapel_profiles-0.3.1/errors.py +34 -0
  12. stapel_profiles-0.3.1/events.py +36 -0
  13. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_cn.svg +15 -0
  14. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_cs.svg +12 -0
  15. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_de.svg +12 -0
  16. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_en.svg +23 -0
  17. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_es.svg +11 -0
  18. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_fr.svg +12 -0
  19. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_hr.svg +237 -0
  20. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_hu.svg +12 -0
  21. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_in.svg +63 -0
  22. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_it.svg +12 -0
  23. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_jp.svg +11 -0
  24. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_kr.svg +24 -0
  25. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_lu.svg +12 -0
  26. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_nl.svg +12 -0
  27. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_pl.svg +11 -0
  28. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_pt.svg +15 -0
  29. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_rs.svg +290 -0
  30. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_ru.svg +17 -0
  31. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_sv.svg +12 -0
  32. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_tr.svg +13 -0
  33. stapel_profiles-0.3.1/fixtures/flags/ic_stpl_flag_ua.svg +11 -0
  34. stapel_profiles-0.3.1/fixtures/languages.json +35 -0
  35. stapel_profiles-0.3.1/gdpr.py +55 -0
  36. stapel_profiles-0.3.1/management/__init__.py +0 -0
  37. stapel_profiles-0.3.1/management/commands/__init__.py +0 -0
  38. stapel_profiles-0.3.1/management/commands/publish_all_profiles.py +60 -0
  39. stapel_profiles-0.3.1/management/commands/sync_languages.py +82 -0
  40. stapel_profiles-0.3.1/migrations/0001_initial.py +62 -0
  41. stapel_profiles-0.3.1/migrations/0002_add_avatar_field.py +19 -0
  42. stapel_profiles-0.3.1/migrations/0003_add_display_name.py +18 -0
  43. stapel_profiles-0.3.1/migrations/0004_add_settings_fields.py +28 -0
  44. stapel_profiles-0.3.1/migrations/0005_add_location_id.py +18 -0
  45. stapel_profiles-0.3.1/migrations/0006_add_location_display_names.py +31 -0
  46. stapel_profiles-0.3.1/migrations/0007_add_initial_setup_passed.py +18 -0
  47. stapel_profiles-0.3.1/migrations/0008_add_language_is_active.py +18 -0
  48. stapel_profiles-0.3.1/migrations/0009_alter_display_name_max_length.py +18 -0
  49. stapel_profiles-0.3.1/migrations/0010_add_notification_prefs.py +33 -0
  50. stapel_profiles-0.3.1/migrations/0011_add_auto_detected_language.py +23 -0
  51. stapel_profiles-0.3.1/migrations/0012_add_rating_fields.py +27 -0
  52. stapel_profiles-0.3.1/migrations/0013_drop_orphan_rating_fields.py +15 -0
  53. stapel_profiles-0.3.1/migrations/__init__.py +0 -0
  54. stapel_profiles-0.3.1/models.py +229 -0
  55. stapel_profiles-0.3.1/py.typed +0 -0
  56. stapel_profiles-0.3.1/pyproject.toml +51 -0
  57. stapel_profiles-0.3.1/schemas/consumes/user.deleted.json +13 -0
  58. stapel_profiles-0.3.1/schemas/consumes/user.deletion_initiated.json +13 -0
  59. stapel_profiles-0.3.1/schemas/emits/profile.changed.json +35 -0
  60. stapel_profiles-0.3.1/serializers.py +419 -0
  61. stapel_profiles-0.3.1/setup.cfg +4 -0
  62. stapel_profiles-0.3.1/stapel_profiles.egg-info/PKG-INFO +57 -0
  63. stapel_profiles-0.3.1/stapel_profiles.egg-info/SOURCES.txt +148 -0
  64. stapel_profiles-0.3.1/stapel_profiles.egg-info/dependency_links.txt +1 -0
  65. stapel_profiles-0.3.1/stapel_profiles.egg-info/requires.txt +7 -0
  66. stapel_profiles-0.3.1/stapel_profiles.egg-info/top_level.txt +1 -0
  67. stapel_profiles-0.3.1/tests/__init__.py +0 -0
  68. stapel_profiles-0.3.1/tests/test_api_profiles.py +147 -0
  69. stapel_profiles-0.3.1/tests/test_api_relationships.py +195 -0
  70. stapel_profiles-0.3.1/tests/test_api_unsubscribe.py +76 -0
  71. stapel_profiles-0.3.1/tests/test_avatar_validation.py +109 -0
  72. stapel_profiles-0.3.1/tests/test_events.py +89 -0
  73. stapel_profiles-0.3.1/tests/test_gdpr_actions.py +153 -0
  74. stapel_profiles-0.3.1/tests/test_management_commands.py +66 -0
  75. stapel_profiles-0.3.1/tests/test_models.py +155 -0
  76. stapel_profiles-0.3.1/tests/test_public_api.py +71 -0
  77. stapel_profiles-0.3.1/tests/test_serializers_extra.py +118 -0
  78. stapel_profiles-0.3.1/urls.py +45 -0
  79. stapel_profiles-0.3.1/validators.py +76 -0
  80. 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
+ [![CI](https://github.com/usestapel/stapel-profiles/actions/workflows/ci.yml/badge.svg)](https://github.com/usestapel/stapel-profiles/actions/workflows/ci.yml)
24
+ [![codecov](https://codecov.io/gh/usestapel/stapel-profiles/graph/badge.svg)](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
+ [![CI](https://github.com/usestapel/stapel-profiles/actions/workflows/ci.yml/badge.svg)](https://github.com/usestapel/stapel-profiles/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/usestapel/stapel-profiles/graph/badge.svg)](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>