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.
@@ -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())
@@ -0,0 +1,8 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class PortfolioConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "portfolio"
7
+ label = "portfolio"
8
+ verbose_name = "Portfolio"