portfolio-package 0.1.0__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.
- portfolio_package-0.1.0/LICENSE +21 -0
- portfolio_package-0.1.0/PKG-INFO +134 -0
- portfolio_package-0.1.0/README.md +108 -0
- portfolio_package-0.1.0/portfolio/__init__.py +1 -0
- portfolio_package-0.1.0/portfolio/admin.py +82 -0
- portfolio_package-0.1.0/portfolio/api.py +164 -0
- portfolio_package-0.1.0/portfolio/apps.py +8 -0
- portfolio_package-0.1.0/portfolio/migrations/0001_initial.py +174 -0
- portfolio_package-0.1.0/portfolio/migrations/0002_project_translations_profile.py +41 -0
- portfolio_package-0.1.0/portfolio/migrations/0003_experience_profile_contact.py +50 -0
- portfolio_package-0.1.0/portfolio/migrations/0004_service_project_case_study.py +83 -0
- portfolio_package-0.1.0/portfolio/migrations/__init__.py +0 -0
- portfolio_package-0.1.0/portfolio/models.py +233 -0
- portfolio_package-0.1.0/portfolio/schemas.py +186 -0
- portfolio_package-0.1.0/portfolio/services.py +165 -0
- portfolio_package-0.1.0/portfolio/tests/__init__.py +0 -0
- portfolio_package-0.1.0/portfolio/tests/test_api.py +178 -0
- portfolio_package-0.1.0/portfolio_package.egg-info/PKG-INFO +134 -0
- portfolio_package-0.1.0/portfolio_package.egg-info/SOURCES.txt +22 -0
- portfolio_package-0.1.0/portfolio_package.egg-info/dependency_links.txt +1 -0
- portfolio_package-0.1.0/portfolio_package.egg-info/requires.txt +10 -0
- portfolio_package-0.1.0/portfolio_package.egg-info/top_level.txt +1 -0
- portfolio_package-0.1.0/pyproject.toml +62 -0
- portfolio_package-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 puzzllium
|
|
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,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: portfolio-package
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Portfolio site package for the Core boilerplate (projects, skills, education, certifications, services, social links). Depends on core-package for auth, RBAC, and the shared User model — it does not define its own.
|
|
5
|
+
Author-email: puzzllium <puzzllium@gmail.com>
|
|
6
|
+
Maintainer-email: puzzllium <puzzllium@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/puzzelchannel/portfolio-package
|
|
9
|
+
Project-URL: Issues, https://github.com/puzzelchannel/portfolio-package/issues
|
|
10
|
+
Classifier: Framework :: Django
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: core-package
|
|
17
|
+
Requires-Dist: blog-package
|
|
18
|
+
Requires-Dist: django-ninja>=1.7.0
|
|
19
|
+
Requires-Dist: django-unfold>=0.81.0
|
|
20
|
+
Requires-Dist: Pillow>=10.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-django>=4.14; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# portfolio-package
|
|
28
|
+
|
|
29
|
+
A portfolio site package for the [Core](../../Core) boilerplate: projects
|
|
30
|
+
(with an image gallery), skills, education, certifications, services, social
|
|
31
|
+
links, and a `Profile` (the About/hero content — headline, tagline, bio,
|
|
32
|
+
stat-box numbers). It depends on [core-package](https://pypi.org/project/core-package/) for auth, RBAC,
|
|
33
|
+
and the shared `User` model — **it never defines its own user/account
|
|
34
|
+
model**; every record here is owned by a Core `User` via
|
|
35
|
+
`settings.AUTH_USER_MODEL`. A user's *name* and *profile picture* live on
|
|
36
|
+
Core's own `User` model (`first_name`/`last_name`/`avatar`), not here.
|
|
37
|
+
|
|
38
|
+
Also depends on `blog-package` — this site's About/portfolio pages include a
|
|
39
|
+
blog section, so installing `portfolio` installs `blog` too (see "Installing
|
|
40
|
+
into a Core-based project" below).
|
|
41
|
+
|
|
42
|
+
`Project.title`/`summary`/`description` and `Profile.headline`/`tagline`/
|
|
43
|
+
`bio` are translatable via `apps.core.models.TranslatableModel` — a single
|
|
44
|
+
`translations` JSON field per row (`{"fa": {"title": "...", ...}, "de":
|
|
45
|
+
{...}}`), not a `title_en`/`title_fa` column per language. Add a language by
|
|
46
|
+
calling `PUT /projects/{id}/translations/{language}` or
|
|
47
|
+
`PUT /profile/me/translations/{language}`, no migration required.
|
|
48
|
+
|
|
49
|
+
See Core's `AGENT.md` §10 ("Package architecture") for the full contract this
|
|
50
|
+
package follows.
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install portfolio-package
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Requires Python 3.10+ and Django 5.2 (via core-package). This also installs
|
|
59
|
+
`blog-package`.
|
|
60
|
+
|
|
61
|
+
## Local development
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
python -m venv .venv
|
|
65
|
+
source .venv/bin/activate
|
|
66
|
+
pip install -e /path/to/Core/Core # core-package, editable
|
|
67
|
+
pip install -e ".[dev]"
|
|
68
|
+
pytest
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Installing into a Core-based project
|
|
72
|
+
|
|
73
|
+
1. Install it into the host repo's venv:
|
|
74
|
+
```bash
|
|
75
|
+
pip install portfolio-package
|
|
76
|
+
```
|
|
77
|
+
This also pulls in `blog-package`. You still need to do steps 2-4 for
|
|
78
|
+
**both** `portfolio` and `blog` — Core's "no auto-discovery, explicit
|
|
79
|
+
wiring" convention (§10) means the pip install is automatic, but
|
|
80
|
+
`INSTALLED_APPS`/router-mounting/`migrate` are not; see `blog`'s own
|
|
81
|
+
README for its exact `INSTALLED_APPS`/router lines. For local development
|
|
82
|
+
of either package, install it editable instead
|
|
83
|
+
(`pip install -e ../Packages/portfolio-package`).
|
|
84
|
+
2. Add it to `INSTALLED_APPS` in the host's `config/settings/base.py`, under
|
|
85
|
+
the `# site packages` comment (after the local apps block):
|
|
86
|
+
```python
|
|
87
|
+
# site packages
|
|
88
|
+
"blog",
|
|
89
|
+
"portfolio",
|
|
90
|
+
```
|
|
91
|
+
3. Mount its router in the host's `config/api.py`:
|
|
92
|
+
```python
|
|
93
|
+
from blog.api import router as blog_router
|
|
94
|
+
from portfolio.api import router as portfolio_router
|
|
95
|
+
...
|
|
96
|
+
api.add_router("/blog", blog_router)
|
|
97
|
+
api.add_router("/portfolio", portfolio_router)
|
|
98
|
+
```
|
|
99
|
+
Since both are wildcard-free routers, order relative to the other
|
|
100
|
+
`add_router` calls doesn't matter.
|
|
101
|
+
4. Migrate:
|
|
102
|
+
```bash
|
|
103
|
+
python manage.py migrate
|
|
104
|
+
```
|
|
105
|
+
5. Optional — add a nav entry under `UNFOLD["SIDEBAR"]["navigation"]` in the
|
|
106
|
+
host's `config/settings/base.py` for its admin models (`Project`, `Profile`,
|
|
107
|
+
`Skill`, etc., and `blog`'s `Post`, `Comment`, etc.).
|
|
108
|
+
|
|
109
|
+
## Endpoints
|
|
110
|
+
|
|
111
|
+
Mounted at whatever prefix the host chooses (`/portfolio` in the example
|
|
112
|
+
above):
|
|
113
|
+
|
|
114
|
+
- `GET /projects` — paginated list of published projects (public), optional
|
|
115
|
+
`?user_id=` and `?lang=`
|
|
116
|
+
- `GET /projects/by-slug/{slug}` — published project detail (public),
|
|
117
|
+
optional `?lang=`
|
|
118
|
+
- `GET /profile?user_id=` — a user's About/hero content (public), optional
|
|
119
|
+
`?lang=`
|
|
120
|
+
- `GET /skills`, `/services`, `/education`, `/certifications`,
|
|
121
|
+
`/social-links` — public read-only lists, optionally filtered by
|
|
122
|
+
`?user_id=`
|
|
123
|
+
- `POST /projects`, `GET /projects/{project_id}`,
|
|
124
|
+
`PATCH /projects/{project_id}`, `DELETE /projects/{project_id}` —
|
|
125
|
+
admin-only (`require_role("admin")`, from Core)
|
|
126
|
+
- `PUT /projects/{project_id}/translations/{language}` — admin-only; sets
|
|
127
|
+
the translated `title`/`summary`/`description` for that language
|
|
128
|
+
- `PATCH /profile/me` — admin-only; create/update the caller's own `Profile`
|
|
129
|
+
- `PUT /profile/me/translations/{language}` — admin-only; sets the
|
|
130
|
+
translated `headline`/`tagline`/`bio` for that language
|
|
131
|
+
|
|
132
|
+
Skills/education/certifications/services/social-links/project images are
|
|
133
|
+
managed through the Django admin (no write API for them yet —
|
|
134
|
+
deliberately, to avoid building endpoints nothing needs yet).
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# portfolio-package
|
|
2
|
+
|
|
3
|
+
A portfolio site package for the [Core](../../Core) boilerplate: projects
|
|
4
|
+
(with an image gallery), skills, education, certifications, services, social
|
|
5
|
+
links, and a `Profile` (the About/hero content — headline, tagline, bio,
|
|
6
|
+
stat-box numbers). It depends on [core-package](https://pypi.org/project/core-package/) for auth, RBAC,
|
|
7
|
+
and the shared `User` model — **it never defines its own user/account
|
|
8
|
+
model**; every record here is owned by a Core `User` via
|
|
9
|
+
`settings.AUTH_USER_MODEL`. A user's *name* and *profile picture* live on
|
|
10
|
+
Core's own `User` model (`first_name`/`last_name`/`avatar`), not here.
|
|
11
|
+
|
|
12
|
+
Also depends on `blog-package` — this site's About/portfolio pages include a
|
|
13
|
+
blog section, so installing `portfolio` installs `blog` too (see "Installing
|
|
14
|
+
into a Core-based project" below).
|
|
15
|
+
|
|
16
|
+
`Project.title`/`summary`/`description` and `Profile.headline`/`tagline`/
|
|
17
|
+
`bio` are translatable via `apps.core.models.TranslatableModel` — a single
|
|
18
|
+
`translations` JSON field per row (`{"fa": {"title": "...", ...}, "de":
|
|
19
|
+
{...}}`), not a `title_en`/`title_fa` column per language. Add a language by
|
|
20
|
+
calling `PUT /projects/{id}/translations/{language}` or
|
|
21
|
+
`PUT /profile/me/translations/{language}`, no migration required.
|
|
22
|
+
|
|
23
|
+
See Core's `AGENT.md` §10 ("Package architecture") for the full contract this
|
|
24
|
+
package follows.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install portfolio-package
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Python 3.10+ and Django 5.2 (via core-package). This also installs
|
|
33
|
+
`blog-package`.
|
|
34
|
+
|
|
35
|
+
## Local development
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python -m venv .venv
|
|
39
|
+
source .venv/bin/activate
|
|
40
|
+
pip install -e /path/to/Core/Core # core-package, editable
|
|
41
|
+
pip install -e ".[dev]"
|
|
42
|
+
pytest
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Installing into a Core-based project
|
|
46
|
+
|
|
47
|
+
1. Install it into the host repo's venv:
|
|
48
|
+
```bash
|
|
49
|
+
pip install portfolio-package
|
|
50
|
+
```
|
|
51
|
+
This also pulls in `blog-package`. You still need to do steps 2-4 for
|
|
52
|
+
**both** `portfolio` and `blog` — Core's "no auto-discovery, explicit
|
|
53
|
+
wiring" convention (§10) means the pip install is automatic, but
|
|
54
|
+
`INSTALLED_APPS`/router-mounting/`migrate` are not; see `blog`'s own
|
|
55
|
+
README for its exact `INSTALLED_APPS`/router lines. For local development
|
|
56
|
+
of either package, install it editable instead
|
|
57
|
+
(`pip install -e ../Packages/portfolio-package`).
|
|
58
|
+
2. Add it to `INSTALLED_APPS` in the host's `config/settings/base.py`, under
|
|
59
|
+
the `# site packages` comment (after the local apps block):
|
|
60
|
+
```python
|
|
61
|
+
# site packages
|
|
62
|
+
"blog",
|
|
63
|
+
"portfolio",
|
|
64
|
+
```
|
|
65
|
+
3. Mount its router in the host's `config/api.py`:
|
|
66
|
+
```python
|
|
67
|
+
from blog.api import router as blog_router
|
|
68
|
+
from portfolio.api import router as portfolio_router
|
|
69
|
+
...
|
|
70
|
+
api.add_router("/blog", blog_router)
|
|
71
|
+
api.add_router("/portfolio", portfolio_router)
|
|
72
|
+
```
|
|
73
|
+
Since both are wildcard-free routers, order relative to the other
|
|
74
|
+
`add_router` calls doesn't matter.
|
|
75
|
+
4. Migrate:
|
|
76
|
+
```bash
|
|
77
|
+
python manage.py migrate
|
|
78
|
+
```
|
|
79
|
+
5. Optional — add a nav entry under `UNFOLD["SIDEBAR"]["navigation"]` in the
|
|
80
|
+
host's `config/settings/base.py` for its admin models (`Project`, `Profile`,
|
|
81
|
+
`Skill`, etc., and `blog`'s `Post`, `Comment`, etc.).
|
|
82
|
+
|
|
83
|
+
## Endpoints
|
|
84
|
+
|
|
85
|
+
Mounted at whatever prefix the host chooses (`/portfolio` in the example
|
|
86
|
+
above):
|
|
87
|
+
|
|
88
|
+
- `GET /projects` — paginated list of published projects (public), optional
|
|
89
|
+
`?user_id=` and `?lang=`
|
|
90
|
+
- `GET /projects/by-slug/{slug}` — published project detail (public),
|
|
91
|
+
optional `?lang=`
|
|
92
|
+
- `GET /profile?user_id=` — a user's About/hero content (public), optional
|
|
93
|
+
`?lang=`
|
|
94
|
+
- `GET /skills`, `/services`, `/education`, `/certifications`,
|
|
95
|
+
`/social-links` — public read-only lists, optionally filtered by
|
|
96
|
+
`?user_id=`
|
|
97
|
+
- `POST /projects`, `GET /projects/{project_id}`,
|
|
98
|
+
`PATCH /projects/{project_id}`, `DELETE /projects/{project_id}` —
|
|
99
|
+
admin-only (`require_role("admin")`, from Core)
|
|
100
|
+
- `PUT /projects/{project_id}/translations/{language}` — admin-only; sets
|
|
101
|
+
the translated `title`/`summary`/`description` for that language
|
|
102
|
+
- `PATCH /profile/me` — admin-only; create/update the caller's own `Profile`
|
|
103
|
+
- `PUT /profile/me/translations/{language}` — admin-only; sets the
|
|
104
|
+
translated `headline`/`tagline`/`bio` for that language
|
|
105
|
+
|
|
106
|
+
Skills/education/certifications/services/social-links/project images are
|
|
107
|
+
managed through the Django admin (no write API for them yet —
|
|
108
|
+
deliberately, to avoid building endpoints nothing needs yet).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
from unfold.admin import ModelAdmin, TabularInline
|
|
3
|
+
|
|
4
|
+
from portfolio.models import (
|
|
5
|
+
Certification,
|
|
6
|
+
Education,
|
|
7
|
+
Experience,
|
|
8
|
+
Profile,
|
|
9
|
+
Project,
|
|
10
|
+
ProjectImage,
|
|
11
|
+
Service,
|
|
12
|
+
Skill,
|
|
13
|
+
SocialLink,
|
|
14
|
+
UserSkill,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProjectImageInline(TabularInline):
|
|
19
|
+
model = ProjectImage
|
|
20
|
+
extra = 1
|
|
21
|
+
fields = ("image", "caption", "order")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@admin.register(Profile)
|
|
25
|
+
class ProfileAdmin(ModelAdmin):
|
|
26
|
+
list_display = ("user", "headline", "years_experience", "clients_count", "projects_count")
|
|
27
|
+
search_fields = ("headline", "tagline", "bio")
|
|
28
|
+
readonly_fields = ("id", "created_at", "updated_at")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@admin.register(Project)
|
|
32
|
+
class ProjectAdmin(ModelAdmin):
|
|
33
|
+
list_display = ("title", "user", "is_published", "order", "created_at")
|
|
34
|
+
list_filter = ("is_published",)
|
|
35
|
+
search_fields = ("title", "slug", "summary")
|
|
36
|
+
prepopulated_fields = {"slug": ("title",)}
|
|
37
|
+
readonly_fields = ("id", "created_at", "updated_at")
|
|
38
|
+
filter_horizontal = ("services",)
|
|
39
|
+
inlines = [ProjectImageInline]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@admin.register(Skill)
|
|
43
|
+
class SkillAdmin(ModelAdmin):
|
|
44
|
+
list_display = ("name", "slug")
|
|
45
|
+
search_fields = ("name",)
|
|
46
|
+
prepopulated_fields = {"slug": ("name",)}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@admin.register(UserSkill)
|
|
50
|
+
class UserSkillAdmin(ModelAdmin):
|
|
51
|
+
list_display = ("user", "skill", "proficiency")
|
|
52
|
+
list_filter = ("skill",)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@admin.register(SocialLink)
|
|
56
|
+
class SocialLinkAdmin(ModelAdmin):
|
|
57
|
+
list_display = ("user", "platform", "url")
|
|
58
|
+
list_filter = ("platform",)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@admin.register(Education)
|
|
62
|
+
class EducationAdmin(ModelAdmin):
|
|
63
|
+
list_display = ("user", "institution", "degree", "start_date", "end_date")
|
|
64
|
+
search_fields = ("institution", "degree", "field_of_study")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@admin.register(Experience)
|
|
68
|
+
class ExperienceAdmin(ModelAdmin):
|
|
69
|
+
list_display = ("user", "title", "company", "start_date", "end_date")
|
|
70
|
+
search_fields = ("title", "company")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@admin.register(Certification)
|
|
74
|
+
class CertificationAdmin(ModelAdmin):
|
|
75
|
+
list_display = ("user", "title", "issuer", "issue_date")
|
|
76
|
+
search_fields = ("title", "issuer")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@admin.register(Service)
|
|
80
|
+
class ServiceAdmin(ModelAdmin):
|
|
81
|
+
list_display = ("title", "user", "order")
|
|
82
|
+
prepopulated_fields = {"slug": ("title",)}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from apps.core.pagination import PageParams, paginate_queryset
|
|
4
|
+
from apps.core.permissions import require_role
|
|
5
|
+
from apps.core.schemas import PagedResponse
|
|
6
|
+
from ninja import Router, Status
|
|
7
|
+
|
|
8
|
+
from portfolio import services
|
|
9
|
+
from portfolio.models import Certification, Education, Experience, Service, Skill, SocialLink, UserSkill
|
|
10
|
+
from portfolio.schemas import (
|
|
11
|
+
CertificationOut,
|
|
12
|
+
EducationOut,
|
|
13
|
+
ExperienceOut,
|
|
14
|
+
ProfileOut,
|
|
15
|
+
ProfileTranslationIn,
|
|
16
|
+
ProfileUpdateIn,
|
|
17
|
+
ProjectIn,
|
|
18
|
+
ProjectOut,
|
|
19
|
+
ProjectTranslationIn,
|
|
20
|
+
ProjectUpdateIn,
|
|
21
|
+
ServiceOut,
|
|
22
|
+
SkillOut,
|
|
23
|
+
SocialLinkOut,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
router = Router(tags=["portfolio"])
|
|
27
|
+
|
|
28
|
+
# NOTE: the public detail lookup lives at "/projects/by-slug/{slug}" (two
|
|
29
|
+
# segments) rather than "/projects/{slug}", specifically to avoid sharing a
|
|
30
|
+
# URL shape with the admin "/projects/{project_id}" route below. Two
|
|
31
|
+
# differently-named wildcards at the same single-segment position
|
|
32
|
+
# ("/projects/{slug}" vs "/projects/{project_id}") would compile to
|
|
33
|
+
# identical regexes but be registered as separate Django urlpatterns bound to
|
|
34
|
+
# disjoint HTTP methods (GET-only vs PATCH/DELETE-only) — whichever is
|
|
35
|
+
# registered first "wins" the match for every method, and Django returns 405
|
|
36
|
+
# for the other route's methods rather than trying the next pattern. See
|
|
37
|
+
# Core's AGENT.md §9 point 5 for the same class of bug. Giving the public
|
|
38
|
+
# lookup a distinct path shape sidesteps it entirely rather than relying on
|
|
39
|
+
# registration order.
|
|
40
|
+
|
|
41
|
+
# --- Public read endpoints --------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.get("/projects", response=PagedResponse[ProjectOut], auth=None)
|
|
45
|
+
async def list_projects(request, page: PageParams = PageParams(), user_id: uuid.UUID | None = None, lang: str | None = None): # noqa: B008 (ninja query-schema idiom)
|
|
46
|
+
items, meta = await paginate_queryset(services.published_project_queryset(user_id), page)
|
|
47
|
+
return {"items": services.serialize_projects(items, lang), "meta": meta}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.get("/projects/by-slug/{slug}", response=ProjectOut, auth=None)
|
|
51
|
+
async def get_project_by_slug(request, slug: str, lang: str | None = None):
|
|
52
|
+
project = await services.get_published_project_or_404(slug)
|
|
53
|
+
return services.serialize_projects([project], lang)[0]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.get("/profile", response=ProfileOut, auth=None)
|
|
57
|
+
async def get_profile(request, user_id: uuid.UUID, lang: str | None = None):
|
|
58
|
+
return await services.get_profile_or_404(user_id, lang)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.get("/skills", response=list[SkillOut], auth=None)
|
|
62
|
+
async def list_skills(request, user_id: uuid.UUID | None = None):
|
|
63
|
+
"""Without `user_id`: the global skill catalog. With it: that owner's
|
|
64
|
+
skills (`UserSkill`) with their proficiency, highest first."""
|
|
65
|
+
if user_id is None:
|
|
66
|
+
return [s async for s in Skill.objects.all()]
|
|
67
|
+
qs = UserSkill.objects.filter(user_id=user_id).select_related("skill").order_by("-proficiency", "skill__name")
|
|
68
|
+
return [
|
|
69
|
+
{"id": us.skill.id, "name": us.skill.name, "slug": us.skill.slug, "proficiency": us.proficiency}
|
|
70
|
+
async for us in qs
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@router.get("/services", response=list[ServiceOut], auth=None)
|
|
75
|
+
async def list_services(request, user_id: uuid.UUID | None = None):
|
|
76
|
+
qs = Service.objects.all()
|
|
77
|
+
if user_id is not None:
|
|
78
|
+
qs = qs.filter(user_id=user_id)
|
|
79
|
+
return [s async for s in qs]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.get("/education", response=list[EducationOut], auth=None)
|
|
83
|
+
async def list_education(request, user_id: uuid.UUID | None = None):
|
|
84
|
+
qs = Education.objects.all()
|
|
85
|
+
if user_id is not None:
|
|
86
|
+
qs = qs.filter(user_id=user_id)
|
|
87
|
+
return [e async for e in qs]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.get("/experience", response=list[ExperienceOut], auth=None)
|
|
91
|
+
async def list_experience(request, user_id: uuid.UUID | None = None):
|
|
92
|
+
qs = Experience.objects.all()
|
|
93
|
+
if user_id is not None:
|
|
94
|
+
qs = qs.filter(user_id=user_id)
|
|
95
|
+
return [e async for e in qs]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@router.get("/certifications", response=list[CertificationOut], auth=None)
|
|
99
|
+
async def list_certifications(request, user_id: uuid.UUID | None = None):
|
|
100
|
+
qs = Certification.objects.all()
|
|
101
|
+
if user_id is not None:
|
|
102
|
+
qs = qs.filter(user_id=user_id)
|
|
103
|
+
return [c async for c in qs]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@router.get("/social-links", response=list[SocialLinkOut], auth=None)
|
|
107
|
+
async def list_social_links(request, user_id: uuid.UUID | None = None):
|
|
108
|
+
qs = SocialLink.objects.all()
|
|
109
|
+
if user_id is not None:
|
|
110
|
+
qs = qs.filter(user_id=user_id)
|
|
111
|
+
return [s async for s in qs]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# --- Admin-only write endpoints (Project) -----------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@router.post("/projects", response={201: ProjectOut})
|
|
118
|
+
@require_role("admin")
|
|
119
|
+
async def create_project(request, payload: ProjectIn):
|
|
120
|
+
project = await services.create_project(request.auth, request.auth.id, **payload.model_dump())
|
|
121
|
+
return Status(201, services.serialize_projects([project])[0])
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@router.get("/projects/{project_id}", response=ProjectOut)
|
|
125
|
+
@require_role("admin")
|
|
126
|
+
async def get_project(request, project_id: uuid.UUID):
|
|
127
|
+
project = await services.get_project_or_404(project_id)
|
|
128
|
+
return services.serialize_projects([project])[0]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@router.patch("/projects/{project_id}", response=ProjectOut)
|
|
132
|
+
@require_role("admin")
|
|
133
|
+
async def update_project(request, project_id: uuid.UUID, payload: ProjectUpdateIn):
|
|
134
|
+
project = await services.update_project(request.auth, project_id, **payload.model_dump())
|
|
135
|
+
return services.serialize_projects([project])[0]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@router.delete("/projects/{project_id}", response={204: None})
|
|
139
|
+
@require_role("admin")
|
|
140
|
+
async def delete_project(request, project_id: uuid.UUID):
|
|
141
|
+
await services.delete_project(request.auth, project_id)
|
|
142
|
+
return Status(204, None)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.put("/projects/{project_id}/translations/{language}", response=ProjectOut)
|
|
146
|
+
@require_role("admin")
|
|
147
|
+
async def set_project_translation(request, project_id: uuid.UUID, language: str, payload: ProjectTranslationIn):
|
|
148
|
+
project = await services.set_project_translation(request.auth, project_id, language, **payload.model_dump())
|
|
149
|
+
return services.serialize_projects([project], language)[0]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# --- Own profile (About/hero content) ----------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@router.patch("/profile/me", response=ProfileOut)
|
|
156
|
+
@require_role("admin")
|
|
157
|
+
async def update_my_profile(request, payload: ProfileUpdateIn):
|
|
158
|
+
return await services.update_own_profile(request.auth, **payload.model_dump())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@router.put("/profile/me/translations/{language}", response=ProfileOut)
|
|
162
|
+
@require_role("admin")
|
|
163
|
+
async def set_my_profile_translation(request, language: str, payload: ProfileTranslationIn):
|
|
164
|
+
return await services.set_profile_translation(request.auth, language, **payload.model_dump())
|