pyferro-demo 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.
- pyferro_demo-0.1.0/PKG-INFO +77 -0
- pyferro_demo-0.1.0/README.md +64 -0
- pyferro_demo-0.1.0/pyproject.toml +38 -0
- pyferro_demo-0.1.0/setup.cfg +4 -0
- pyferro_demo-0.1.0/src/pyferro_demo/__init__.py +5 -0
- pyferro_demo-0.1.0/src/pyferro_demo/__main__.py +4 -0
- pyferro_demo-0.1.0/src/pyferro_demo/app.py +44 -0
- pyferro_demo-0.1.0/src/pyferro_demo/cli.py +52 -0
- pyferro_demo-0.1.0/src/pyferro_demo/forum/__init__.py +15 -0
- pyferro_demo-0.1.0/src/pyferro_demo/forum/models.py +70 -0
- pyferro_demo-0.1.0/src/pyferro_demo/forum/rpc.py +108 -0
- pyferro_demo-0.1.0/src/pyferro_demo/forum/seeder.py +184 -0
- pyferro_demo-0.1.0/src/pyferro_demo/forum/seo.py +122 -0
- pyferro_demo-0.1.0/src/pyferro_demo.egg-info/PKG-INFO +77 -0
- pyferro_demo-0.1.0/src/pyferro_demo.egg-info/SOURCES.txt +18 -0
- pyferro_demo-0.1.0/src/pyferro_demo.egg-info/dependency_links.txt +1 -0
- pyferro_demo-0.1.0/src/pyferro_demo.egg-info/entry_points.txt +2 -0
- pyferro_demo-0.1.0/src/pyferro_demo.egg-info/requires.txt +1 -0
- pyferro_demo-0.1.0/src/pyferro_demo.egg-info/top_level.txt +1 -0
- pyferro_demo-0.1.0/tests/test_forum_e2e.py +60 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyferro-demo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Showcase & educational demo application built on the PyFerro framework (SEO Forum, JSON-RPC, Granian RSGI).
|
|
5
|
+
Author-email: Alex Titoff <a.tit.off@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/atitoff-dotcom/PyFerro
|
|
8
|
+
Project-URL: Repository, https://github.com/atitoff-dotcom/PyFerro.git
|
|
9
|
+
Keywords: pyferro,demo,forum,showcase,rsgi,wsrpc
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: pyferro>=2.1.0
|
|
13
|
+
|
|
14
|
+
# π PyFerro Showcase & Demo Application
|
|
15
|
+
|
|
16
|
+
ΠΡΠΈΡΠΈΠ°Π»ΡΠ½ΠΎΠ΅ Π΄Π΅ΠΌΠΎΠ½ΡΡΡΠ°ΡΠΈΠΎΠ½Π½ΠΎΠ΅ ΠΈ ΠΎΠ±ΡΡΠ°ΡΡΠ΅Π΅ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ Π½Π° ΡΠ΅Π°ΠΊΡΠΈΠ²Π½ΠΎΠΌ ΡΡΠ΅ΠΉΠΌΠ²ΠΎΡΠΊΠ΅ **PyFerro**.
|
|
17
|
+
Π‘Π»ΡΠΆΠΈΡ ΠΎΠ±ΡΠ°Π·ΡΠΎΠΌ Π°ΡΡ
ΠΈΡΠ΅ΠΊΡΡΡΡ, ΠΆΠΈΠ²ΡΠΌ ΠΏΡΠΈΠΌΠ΅ΡΠΎΠΌ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°Π½ΠΈΡ ΠΈ E2E-ΡΠ΅ΡΡΠ΅ΡΠΎΠΌ ΡΠ΄ΡΠ°.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## π‘ ΠΡΠΎΠ±Π΅Π½Π½ΠΎΡΡΠΈ
|
|
22
|
+
|
|
23
|
+
- **ΠΠ·ΠΎΠ»ΠΈΡΠΎΠ²Π°Π½Π½Π°Ρ Π±Π°Π·Π° Π΄Π°Π½Π½ΡΡ
:** ΠΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΏΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ ΡΠΎΠ·Π΄Π°Π΅Ρ ΠΈ ΠΈΡΠΏΠΎΠ»ΡΠ·ΡΠ΅Ρ Π»ΠΎΠΊΠ°Π»ΡΠ½ΡΡ Π±Π°Π·Ρ Π΄Π°Π½Π½ΡΡ
`demo.db` (SQLite). ΠΠ°ΡΠ° ΠΊΠΎΡΠΏΠΎΡΠ°ΡΠΈΠ²Π½Π°Ρ ΠΠ ΠΎΡΡΠ°Π΅ΡΡΡ Π² ΠΏΠΎΠ»Π½ΠΎΠΉ Π±Π΅Π·ΠΎΠΏΠ°ΡΠ½ΠΎΡΡΠΈ.
|
|
24
|
+
- **ΠΠΎΠ»Π½ΠΎΡΡΠ½ΠΊΡΠΈΠΎΠ½Π°Π»ΡΠ½ΡΠΉ SEO-ΡΠΎΡΡΠΌ:**
|
|
25
|
+
- ΠΡΠ΅Π²ΠΎΠ²ΠΈΠ΄Π½Π°Ρ ΡΡΡΡΠΊΡΡΡΠ° ΡΠ°Π·Π΄Π΅Π»ΠΎΠ² ΠΈ Π²Π΅ΡΠΎΠΊ (`Topic`, `Message`).
|
|
26
|
+
- ΠΠΈΠΊΡΠΎΡΠ°Π·ΠΌΠ΅ΡΠΊΠ° Schema.org (`BreadcrumbList`, `DiscussionForumPosting` Π² JSON-LD).
|
|
27
|
+
- ΠΠΈΠ½Π°ΠΌΠΈΡΠ΅ΡΠΊΠΈΠΉ ΡΠ΅Π½Π΄Π΅ΡΠΈΠ½Π³ ΡΡΡΠ°Π½ΠΈΡ Π΄Π»Ρ ΠΏΠΎΠΈΡΠΊΠΎΠ²ΡΡ
Π±ΠΎΡΠΎΠ² (Jinja2).
|
|
28
|
+
- **Π‘ΠΈΠΌΠΌΠ΅ΡΡΠΈΡΠ½ΡΠΉ JSON-RPC 2.0:** ΠΡΠΈΠΌΠ΅ΡΡ Π΄Π΅ΠΊΠ»Π°ΡΠ°ΡΠΈΠΈ ΠΌΠ΅ΡΠΎΠ΄ΠΎΠ² Ρ ΠΊΠΎΠ½ΡΡΠΎΠ»Π΅ΠΌ ΡΠΎΠ»Π΅ΠΉ (RBAC).
|
|
29
|
+
- **ΠΠΎΡΠΎΠ²ΡΠ΅ ΡΠΈΠ΄-Π΄Π°Π½Π½ΡΠ΅:** ΠΠ²ΡΠΎΠΌΠ°ΡΠΈΡΠ΅ΡΠΊΠΎΠ΅ Π½Π°ΠΏΠΎΠ»Π½Π΅Π½ΠΈΠ΅ 10 ΡΠ΅ΠΌΠ°ΠΌΠΈ ΠΈ Π²Π΅ΡΠΊΠ°ΠΌΠΈ ΠΎΠ±ΡΡΠΆΠ΄Π΅Π½ΠΈΠΉ ΠΏΡΠΈ ΠΏΠ΅ΡΠ²ΠΎΠΌ Π·Π°ΠΏΡΡΠΊΠ΅.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## β‘ ΠΡΡΡΡΡΠΉ ΡΡΠ°ΡΡ
|
|
34
|
+
|
|
35
|
+
### 1. Π£ΡΡΠ°Π½ΠΎΠ²ΠΊΠ° ΠΈΠ· PyPI:
|
|
36
|
+
```bash
|
|
37
|
+
pip install pyferro-demo
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 2. ΠΠ°ΠΏΡΡΠΊ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ:
|
|
41
|
+
```bash
|
|
42
|
+
pyferro-demo run
|
|
43
|
+
```
|
|
44
|
+
ΠΈΠ»ΠΈ Π±Π΅Π· ΠΏΡΠ΅Π΄Π²Π°ΡΠΈΡΠ΅Π»ΡΠ½ΠΎΠΉ ΡΡΡΠ°Π½ΠΎΠ²ΠΊΠΈ:
|
|
45
|
+
```bash
|
|
46
|
+
pipx run pyferro-demo
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
ΠΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ Π·Π°ΠΏΡΡΡΠΈΡΡΡ Π½Π° Rust-ΡΠ΅ΡΠ²Π΅ΡΠ΅ Granian:
|
|
50
|
+
- **ΠΠ΄ΠΌΠΈΠ½ΠΊΠ° PyFerro:** [http://localhost:8080/pyferro_admin/](http://localhost:8080/pyferro_admin/) (Π»ΠΎΠ³ΠΈΠ½: `admin`, ΠΏΠ°ΡΠΎΠ»Ρ: `admin`)
|
|
51
|
+
- **JSON-RPC ΡΠ½Π΄ΠΏΠΎΠΈΠ½Ρ:** `ws://localhost:8080/wsrpc`
|
|
52
|
+
- **Sitemap XML:** [http://localhost:8080/sitemap.xml](http://localhost:8080/sitemap.xml)
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## π Π‘ΡΡΡΠΊΡΡΡΠ° ΠΏΡΠΎΠ΅ΠΊΡΠ°
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
pyferro_demo/
|
|
60
|
+
βββ cli.py # Π’ΠΎΡΠΊΠ° Π²Ρ
ΠΎΠ΄Π° ΠΊΠΎΠ½ΡΠΎΠ»ΡΠ½ΠΎΠΉ ΠΊΠΎΠΌΠ°Π½Π΄Ρ pyferro-demo
|
|
61
|
+
βββ app.py # Π€Π°Π±ΡΠΈΠΊΠ° ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ PyFerroApp ΠΈ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΡ ΠΌΠΎΠ΄ΡΠ»Π΅ΠΉ
|
|
62
|
+
βββ forum/
|
|
63
|
+
β βββ models.py # SQLAlchemy-ΠΌΠΎΠ΄Π΅Π»ΠΈ ΡΠΎΠΏΠΈΠΊΠΎΠ² ΠΈ ΡΠΎΠΎΠ±ΡΠ΅Π½ΠΈΠΉ
|
|
64
|
+
β βββ rpc.py # JSON-RPC 2.0 ΠΌΠ΅ΡΠΎΠ΄Ρ (forum.list_topics, forum.get_topic ΠΈ Ρ.Π΄.)
|
|
65
|
+
β βββ seeder.py # ΠΠ΄Π΅ΠΌΠΏΠΎΡΠ΅Π½ΡΠ½ΡΠΉ ΡΠΈΠ΄ΠΈΠ½Π³ Π΄Π΅ΠΌΠΎΠ½ΡΡΡΠ°ΡΠΈΠΎΠ½Π½ΡΡ
Π΄Π°Π½Π½ΡΡ
|
|
66
|
+
β βββ seo.py # ΠΠ΅Π½Π΅ΡΠ°ΡΠΈΡ JSON-LD ΠΌΠΈΠΊΡΠΎΡΠ°Π·ΠΌΠ΅ΡΠΊΠΈ ΠΈ Ρ
Π»Π΅Π±Π½ΡΡ
ΠΊΡΠΎΡΠ΅ΠΊ
|
|
67
|
+
βββ templates/
|
|
68
|
+
βββ forum_topic.html # Π¨Π°Π±Π»ΠΎΠ½ Jinja2 Π΄Π»Ρ Dynamic Rendering
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## π§ͺ ΠΠ°ΠΏΡΡΠΊ ΡΠ΅ΡΡΠΎΠ²
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pytest
|
|
77
|
+
```
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# π PyFerro Showcase & Demo Application
|
|
2
|
+
|
|
3
|
+
ΠΡΠΈΡΠΈΠ°Π»ΡΠ½ΠΎΠ΅ Π΄Π΅ΠΌΠΎΠ½ΡΡΡΠ°ΡΠΈΠΎΠ½Π½ΠΎΠ΅ ΠΈ ΠΎΠ±ΡΡΠ°ΡΡΠ΅Π΅ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ Π½Π° ΡΠ΅Π°ΠΊΡΠΈΠ²Π½ΠΎΠΌ ΡΡΠ΅ΠΉΠΌΠ²ΠΎΡΠΊΠ΅ **PyFerro**.
|
|
4
|
+
Π‘Π»ΡΠΆΠΈΡ ΠΎΠ±ΡΠ°Π·ΡΠΎΠΌ Π°ΡΡ
ΠΈΡΠ΅ΠΊΡΡΡΡ, ΠΆΠΈΠ²ΡΠΌ ΠΏΡΠΈΠΌΠ΅ΡΠΎΠΌ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°Π½ΠΈΡ ΠΈ E2E-ΡΠ΅ΡΡΠ΅ΡΠΎΠΌ ΡΠ΄ΡΠ°.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## π‘ ΠΡΠΎΠ±Π΅Π½Π½ΠΎΡΡΠΈ
|
|
9
|
+
|
|
10
|
+
- **ΠΠ·ΠΎΠ»ΠΈΡΠΎΠ²Π°Π½Π½Π°Ρ Π±Π°Π·Π° Π΄Π°Π½Π½ΡΡ
:** ΠΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΏΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ ΡΠΎΠ·Π΄Π°Π΅Ρ ΠΈ ΠΈΡΠΏΠΎΠ»ΡΠ·ΡΠ΅Ρ Π»ΠΎΠΊΠ°Π»ΡΠ½ΡΡ Π±Π°Π·Ρ Π΄Π°Π½Π½ΡΡ
`demo.db` (SQLite). ΠΠ°ΡΠ° ΠΊΠΎΡΠΏΠΎΡΠ°ΡΠΈΠ²Π½Π°Ρ ΠΠ ΠΎΡΡΠ°Π΅ΡΡΡ Π² ΠΏΠΎΠ»Π½ΠΎΠΉ Π±Π΅Π·ΠΎΠΏΠ°ΡΠ½ΠΎΡΡΠΈ.
|
|
11
|
+
- **ΠΠΎΠ»Π½ΠΎΡΡΠ½ΠΊΡΠΈΠΎΠ½Π°Π»ΡΠ½ΡΠΉ SEO-ΡΠΎΡΡΠΌ:**
|
|
12
|
+
- ΠΡΠ΅Π²ΠΎΠ²ΠΈΠ΄Π½Π°Ρ ΡΡΡΡΠΊΡΡΡΠ° ΡΠ°Π·Π΄Π΅Π»ΠΎΠ² ΠΈ Π²Π΅ΡΠΎΠΊ (`Topic`, `Message`).
|
|
13
|
+
- ΠΠΈΠΊΡΠΎΡΠ°Π·ΠΌΠ΅ΡΠΊΠ° Schema.org (`BreadcrumbList`, `DiscussionForumPosting` Π² JSON-LD).
|
|
14
|
+
- ΠΠΈΠ½Π°ΠΌΠΈΡΠ΅ΡΠΊΠΈΠΉ ΡΠ΅Π½Π΄Π΅ΡΠΈΠ½Π³ ΡΡΡΠ°Π½ΠΈΡ Π΄Π»Ρ ΠΏΠΎΠΈΡΠΊΠΎΠ²ΡΡ
Π±ΠΎΡΠΎΠ² (Jinja2).
|
|
15
|
+
- **Π‘ΠΈΠΌΠΌΠ΅ΡΡΠΈΡΠ½ΡΠΉ JSON-RPC 2.0:** ΠΡΠΈΠΌΠ΅ΡΡ Π΄Π΅ΠΊΠ»Π°ΡΠ°ΡΠΈΠΈ ΠΌΠ΅ΡΠΎΠ΄ΠΎΠ² Ρ ΠΊΠΎΠ½ΡΡΠΎΠ»Π΅ΠΌ ΡΠΎΠ»Π΅ΠΉ (RBAC).
|
|
16
|
+
- **ΠΠΎΡΠΎΠ²ΡΠ΅ ΡΠΈΠ΄-Π΄Π°Π½Π½ΡΠ΅:** ΠΠ²ΡΠΎΠΌΠ°ΡΠΈΡΠ΅ΡΠΊΠΎΠ΅ Π½Π°ΠΏΠΎΠ»Π½Π΅Π½ΠΈΠ΅ 10 ΡΠ΅ΠΌΠ°ΠΌΠΈ ΠΈ Π²Π΅ΡΠΊΠ°ΠΌΠΈ ΠΎΠ±ΡΡΠΆΠ΄Π΅Π½ΠΈΠΉ ΠΏΡΠΈ ΠΏΠ΅ΡΠ²ΠΎΠΌ Π·Π°ΠΏΡΡΠΊΠ΅.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## β‘ ΠΡΡΡΡΡΠΉ ΡΡΠ°ΡΡ
|
|
21
|
+
|
|
22
|
+
### 1. Π£ΡΡΠ°Π½ΠΎΠ²ΠΊΠ° ΠΈΠ· PyPI:
|
|
23
|
+
```bash
|
|
24
|
+
pip install pyferro-demo
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### 2. ΠΠ°ΠΏΡΡΠΊ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ:
|
|
28
|
+
```bash
|
|
29
|
+
pyferro-demo run
|
|
30
|
+
```
|
|
31
|
+
ΠΈΠ»ΠΈ Π±Π΅Π· ΠΏΡΠ΅Π΄Π²Π°ΡΠΈΡΠ΅Π»ΡΠ½ΠΎΠΉ ΡΡΡΠ°Π½ΠΎΠ²ΠΊΠΈ:
|
|
32
|
+
```bash
|
|
33
|
+
pipx run pyferro-demo
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
ΠΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ Π·Π°ΠΏΡΡΡΠΈΡΡΡ Π½Π° Rust-ΡΠ΅ΡΠ²Π΅ΡΠ΅ Granian:
|
|
37
|
+
- **ΠΠ΄ΠΌΠΈΠ½ΠΊΠ° PyFerro:** [http://localhost:8080/pyferro_admin/](http://localhost:8080/pyferro_admin/) (Π»ΠΎΠ³ΠΈΠ½: `admin`, ΠΏΠ°ΡΠΎΠ»Ρ: `admin`)
|
|
38
|
+
- **JSON-RPC ΡΠ½Π΄ΠΏΠΎΠΈΠ½Ρ:** `ws://localhost:8080/wsrpc`
|
|
39
|
+
- **Sitemap XML:** [http://localhost:8080/sitemap.xml](http://localhost:8080/sitemap.xml)
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## π Π‘ΡΡΡΠΊΡΡΡΠ° ΠΏΡΠΎΠ΅ΠΊΡΠ°
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
pyferro_demo/
|
|
47
|
+
βββ cli.py # Π’ΠΎΡΠΊΠ° Π²Ρ
ΠΎΠ΄Π° ΠΊΠΎΠ½ΡΠΎΠ»ΡΠ½ΠΎΠΉ ΠΊΠΎΠΌΠ°Π½Π΄Ρ pyferro-demo
|
|
48
|
+
βββ app.py # Π€Π°Π±ΡΠΈΠΊΠ° ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ PyFerroApp ΠΈ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΡ ΠΌΠΎΠ΄ΡΠ»Π΅ΠΉ
|
|
49
|
+
βββ forum/
|
|
50
|
+
β βββ models.py # SQLAlchemy-ΠΌΠΎΠ΄Π΅Π»ΠΈ ΡΠΎΠΏΠΈΠΊΠΎΠ² ΠΈ ΡΠΎΠΎΠ±ΡΠ΅Π½ΠΈΠΉ
|
|
51
|
+
β βββ rpc.py # JSON-RPC 2.0 ΠΌΠ΅ΡΠΎΠ΄Ρ (forum.list_topics, forum.get_topic ΠΈ Ρ.Π΄.)
|
|
52
|
+
β βββ seeder.py # ΠΠ΄Π΅ΠΌΠΏΠΎΡΠ΅Π½ΡΠ½ΡΠΉ ΡΠΈΠ΄ΠΈΠ½Π³ Π΄Π΅ΠΌΠΎΠ½ΡΡΡΠ°ΡΠΈΠΎΠ½Π½ΡΡ
Π΄Π°Π½Π½ΡΡ
|
|
53
|
+
β βββ seo.py # ΠΠ΅Π½Π΅ΡΠ°ΡΠΈΡ JSON-LD ΠΌΠΈΠΊΡΠΎΡΠ°Π·ΠΌΠ΅ΡΠΊΠΈ ΠΈ Ρ
Π»Π΅Π±Π½ΡΡ
ΠΊΡΠΎΡΠ΅ΠΊ
|
|
54
|
+
βββ templates/
|
|
55
|
+
βββ forum_topic.html # Π¨Π°Π±Π»ΠΎΠ½ Jinja2 Π΄Π»Ρ Dynamic Rendering
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## π§ͺ ΠΠ°ΠΏΡΡΠΊ ΡΠ΅ΡΡΠΎΠ²
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pytest
|
|
64
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyferro-demo"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Showcase & educational demo application built on the PyFerro framework (SEO Forum, JSON-RPC, Granian RSGI)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Alex Titoff", email = "a.tit.off@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["pyferro", "demo", "forum", "showcase", "rsgi", "wsrpc"]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"pyferro>=2.1.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
pyferro-demo = "pyferro_demo.cli:main"
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/atitoff-dotcom/PyFerro"
|
|
25
|
+
Repository = "https://github.com/atitoff-dotcom/PyFerro.git"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
29
|
+
include = ["pyferro_demo*"]
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.package-data]
|
|
32
|
+
"pyferro_demo" = [
|
|
33
|
+
"templates/*",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
pythonpath = [".", "src"]
|
|
38
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Π€Π°Π±ΡΠΈΠΊΠ° Π΄Π΅ΠΌΠΎΠ½ΡΡΡΠ°ΡΠΈΠΎΠ½Π½ΠΎΠ³ΠΎ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ PyFerro Demo.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pyferro2.db import Database
|
|
10
|
+
from pyferro2.server import PyFerroApp
|
|
11
|
+
from pyferro_demo.forum.models import ForumBase
|
|
12
|
+
from pyferro_demo.forum.rpc import register_forum_rpc
|
|
13
|
+
from pyferro_demo.forum.seeder import seed_forum_data
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def create_demo_app(
|
|
17
|
+
db_url: str = "sqlite+aiosqlite:///demo.db",
|
|
18
|
+
secret_key: str = "pyferro-demo-secret-key-32-chars-long!",
|
|
19
|
+
) -> PyFerroApp:
|
|
20
|
+
"""
|
|
21
|
+
Π‘ΠΎΠ·Π΄Π°Π΅Ρ ΠΈ Π½Π°ΡΡΡΠ°ΠΈΠ²Π°Π΅Ρ ΡΠΊΠ·Π΅ΠΌΠΏΠ»ΡΡ PyFerroApp Ρ Π΄Π΅ΠΌΠΎΠ½ΡΡΡΠ°ΡΠΈΠΎΠ½Π½ΡΠΌ ΡΠΎΡΡΠΌΠΎΠΌ.
|
|
22
|
+
"""
|
|
23
|
+
db = Database(db_url)
|
|
24
|
+
# Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ ΡΠ°Π±Π»ΠΈΡΡ ΡΠ΄ΡΠ° PyFerro (pf_*)
|
|
25
|
+
await db.create_tables()
|
|
26
|
+
|
|
27
|
+
# Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ ΡΠ°Π±Π»ΠΈΡΡ Π΄Π΅ΠΌΠΎΠ½ΡΡΡΠ°ΡΠΈΠΎΠ½Π½ΠΎΠ³ΠΎ ΡΠΎΡΡΠΌΠ° Π² ΡΠΎΠΉ ΠΆΠ΅ ΠΈΠ·ΠΎΠ»ΠΈΡΠΎΠ²Π°Π½Π½ΠΎΠΉ demo.db
|
|
28
|
+
async with db.engine.begin() as conn:
|
|
29
|
+
await conn.run_sync(ForumBase.metadata.create_all)
|
|
30
|
+
|
|
31
|
+
# ΠΠ½ΠΈΡΠΈΠ°Π»ΠΈΠ·ΠΈΡΡΠ΅ΠΌ ΡΠΈΠ΄-Π΄Π°Π½Π½ΡΠ΅ ΡΠΎΡΡΠΌΠ°
|
|
32
|
+
async with db.session() as session:
|
|
33
|
+
await seed_forum_data(session)
|
|
34
|
+
|
|
35
|
+
# Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ PyFerro
|
|
36
|
+
app = PyFerroApp(
|
|
37
|
+
db=db,
|
|
38
|
+
secret_key=secret_key,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Π Π΅Π³ΠΈΡΡΡΠΈΡΡΠ΅ΠΌ RPC-ΠΌΠ΅ΡΠΎΠ΄Ρ ΡΠΎΡΡΠΌΠ°
|
|
42
|
+
register_forum_rpc(app.router, db)
|
|
43
|
+
|
|
44
|
+
return app
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI ΡΡΠΈΠ»ΠΈΡΠ° Π·Π°ΠΏΡΡΠΊΠ° pyferro-demo.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from pyferro_demo.app import create_demo_app
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="pyferro-demo",
|
|
17
|
+
description="π ΠΠ°ΠΏΡΡΠΊ Π΄Π΅ΠΌΠΎΠ½ΡΡΡΠ°ΡΠΈΠΎΠ½Π½ΠΎΠ³ΠΎ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ PyFerro (Π€ΠΎΡΡΠΌ, JSON-RPC, Granian RSGI)",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"command",
|
|
21
|
+
nargs="?",
|
|
22
|
+
default="run",
|
|
23
|
+
choices=["run"],
|
|
24
|
+
help="ΠΠΎΠΌΠ°Π½Π΄Π° (ΠΏΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ run)",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--host", default="127.0.0.1", help="Π₯ΠΎΡΡ ΡΠ΅ΡΠ²Π΅ΡΠ° (ΠΏΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ 127.0.0.1)"
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--port", type=int, default=8080, help="ΠΠΎΡΡ ΡΠ΅ΡΠ²Π΅ΡΠ° (ΠΏΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ 8080)"
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--db", default="demo.db", help="ΠΡΡΡ ΠΊ SQLite Π±Π°Π·Π΅ Π΄Π°Π½Π½ΡΡ
(ΠΏΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ demo.db)"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
args = parser.parse_args()
|
|
37
|
+
|
|
38
|
+
print("=" * 60)
|
|
39
|
+
print(" π ΠΠ°ΠΏΡΡΠΊ PyFerro Showcase & Demo Application")
|
|
40
|
+
print(f" π¦ ΠΠ°Π·Π° Π΄Π°Π½Π½ΡΡ
: {args.db}")
|
|
41
|
+
print(f" π ΠΠ΄ΡΠ΅Ρ: http://{args.host}:{args.port}")
|
|
42
|
+
print(f" π‘οΈ ΠΠ΄ΠΌΠΈΠ½ΠΊΠ°: http://{args.host}:{args.port}/pyferro_admin/")
|
|
43
|
+
print(" π ΠΠΎΠ³ΠΈΠ½: admin / ΠΠ°ΡΠΎΠ»Ρ: admin")
|
|
44
|
+
print("=" * 60)
|
|
45
|
+
|
|
46
|
+
db_url = f"sqlite+aiosqlite:///{args.db}"
|
|
47
|
+
app = asyncio.run(create_demo_app(db_url=db_url))
|
|
48
|
+
app.run(host=args.host, port=args.port)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
main()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ΠΠΎΠ΄ΡΠ»Ρ ΡΠΎΡΡΠΌΠ°: ΠΌΠΎΠ΄Π΅Π»ΠΈ, RPC ΠΈ Π³Π΅Π½Π΅ΡΠ°ΡΠΎΡΡ SEO.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pyferro_demo.forum.models import ForumBase, Topic, Message
|
|
6
|
+
from pyferro_demo.forum.rpc import register_forum_rpc
|
|
7
|
+
from pyferro_demo.forum.seeder import seed_forum_data
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ForumBase",
|
|
11
|
+
"Topic",
|
|
12
|
+
"Message",
|
|
13
|
+
"register_forum_rpc",
|
|
14
|
+
"seed_forum_data",
|
|
15
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLAlchemy-ΠΌΠΎΠ΄Π΅Π»ΠΈ Π΄Π΅ΠΌΠΎΠ½ΡΡΡΠ°ΡΠΈΠΎΠ½Π½ΠΎΠ³ΠΎ ΡΠΎΡΡΠΌΠ°.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
|
10
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _now_utc() -> datetime:
|
|
14
|
+
return datetime.now(timezone.utc)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ForumBase(DeclarativeBase):
|
|
18
|
+
"""ΠΠ°Π·ΠΎΠ²ΡΠΉ ΠΊΠ»Π°ΡΡ ΠΌΠΎΠ΄Π΅Π»Π΅ΠΉ Π΄Π΅ΠΌΠΎΠ½ΡΡΡΠ°ΡΠΈΠΎΠ½Π½ΠΎΠ³ΠΎ ΡΠΎΡΡΠΌΠ°."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Topic(ForumBase):
|
|
23
|
+
"""ΠΡΠ΅Π²ΠΎΠ²ΠΈΠ΄Π½Π°Ρ ΠΈΠ΅ΡΠ°ΡΡ
ΠΈΡ ΡΠ°Π·Π΄Π΅Π»ΠΎΠ² ΠΈ ΡΠ΅ΠΌ ΡΠΎΡΡΠΌΠ°."""
|
|
24
|
+
|
|
25
|
+
__tablename__ = "forum_topics"
|
|
26
|
+
|
|
27
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
28
|
+
parent_id: Mapped[int | None] = mapped_column(
|
|
29
|
+
Integer,
|
|
30
|
+
ForeignKey("forum_topics.id", ondelete="CASCADE"),
|
|
31
|
+
nullable=True,
|
|
32
|
+
index=True,
|
|
33
|
+
)
|
|
34
|
+
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
35
|
+
slug: Mapped[str] = mapped_column(String(200), unique=True, nullable=False, index=True)
|
|
36
|
+
is_category: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
37
|
+
seo_title: Mapped[str | None] = mapped_column(String(250), nullable=True)
|
|
38
|
+
seo_description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
39
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now_utc)
|
|
40
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
41
|
+
DateTime(timezone=True), default=_now_utc, onupdate=_now_utc
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# ΠΡΠ½ΠΎΡΠ΅Π½ΠΈΠ΅ ΠΊ ΡΠΎΠΎΠ±ΡΠ΅Π½ΠΈΡΠΌ
|
|
45
|
+
messages: Mapped[list[Message]] = relationship(
|
|
46
|
+
"Message",
|
|
47
|
+
back_populates="topic",
|
|
48
|
+
cascade="all, delete-orphan",
|
|
49
|
+
order_by="Message.created_at",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Message(ForumBase):
|
|
54
|
+
"""Π‘ΠΎΠΎΠ±ΡΠ΅Π½ΠΈΡ Π²Π½ΡΡΡΠΈ ΡΠΎΡΡΠΌΠ½ΠΎΠΉ Π²Π΅ΡΠΊΠΈ."""
|
|
55
|
+
|
|
56
|
+
__tablename__ = "forum_messages"
|
|
57
|
+
|
|
58
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
59
|
+
topic_id: Mapped[int] = mapped_column(
|
|
60
|
+
Integer,
|
|
61
|
+
ForeignKey("forum_topics.id", ondelete="CASCADE"),
|
|
62
|
+
nullable=False,
|
|
63
|
+
index=True,
|
|
64
|
+
)
|
|
65
|
+
author_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
|
66
|
+
content: Mapped[str] = mapped_column(Text, nullable=False)
|
|
67
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now_utc)
|
|
68
|
+
|
|
69
|
+
# ΠΠ±ΡΠ°ΡΠ½ΠΎΠ΅ ΠΎΡΠ½ΠΎΡΠ΅Π½ΠΈΠ΅
|
|
70
|
+
topic: Mapped[Topic] = relationship("Topic", back_populates="messages")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JSON-RPC 2.0 ΠΈΠ½ΡΠ΅ΡΡΠ΅ΠΉΡ ΡΠΎΡΡΠΌΠ° (forum.*).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import select
|
|
10
|
+
from sqlalchemy.orm import selectinload
|
|
11
|
+
|
|
12
|
+
from pyferro2.core.session_data import SessionData
|
|
13
|
+
from pyferro2.db.engine import Database
|
|
14
|
+
from pyferro2.rpc.router import RpcRouter
|
|
15
|
+
from pyferro_demo.forum.models import Message, Topic
|
|
16
|
+
from pyferro_demo.forum.seo import get_topic_breadcrumbs, get_topic_discussion_posting
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register_forum_rpc(router: RpcRouter, db: Database) -> None:
|
|
20
|
+
"""Π Π΅Π³ΠΈΡΡΡΠΈΡΡΠ΅Ρ ΠΌΠ΅ΡΠΎΠ΄Ρ `forum.*` Π² RPC-ΡΠΎΡΡΠ΅ΡΠ΅ PyFerro."""
|
|
21
|
+
|
|
22
|
+
class forum:
|
|
23
|
+
@router.rpc_method(public=True)
|
|
24
|
+
async def list_topics(
|
|
25
|
+
session_data: SessionData, parent_id: int | None = None
|
|
26
|
+
) -> list[dict[str, Any]]:
|
|
27
|
+
"""ΠΠΎΠ·Π²ΡΠ°ΡΠ°Π΅Ρ ΡΠΏΠΈΡΠΎΠΊ ΡΠ΅ΠΌ ΡΠΎΡΡΠΌΠ°."""
|
|
28
|
+
async with db.session() as session:
|
|
29
|
+
stmt = select(Topic).where(Topic.parent_id == parent_id)
|
|
30
|
+
res = await session.execute(stmt)
|
|
31
|
+
topics = res.scalars().all()
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
"id": t.id,
|
|
35
|
+
"parent_id": t.parent_id,
|
|
36
|
+
"title": t.title,
|
|
37
|
+
"slug": t.slug,
|
|
38
|
+
"is_category": t.is_category,
|
|
39
|
+
"seo_title": t.seo_title,
|
|
40
|
+
"seo_description": t.seo_description,
|
|
41
|
+
"created_at": t.created_at.isoformat(),
|
|
42
|
+
}
|
|
43
|
+
for t in topics
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
@router.rpc_method(public=True)
|
|
47
|
+
async def get_topic(
|
|
48
|
+
session_data: SessionData, slug: str
|
|
49
|
+
) -> dict[str, Any] | None:
|
|
50
|
+
"""ΠΠΎΠ·Π²ΡΠ°ΡΠ°Π΅Ρ ΡΠ΅ΠΌΡ ΠΈ ΡΠΎΠΎΠ±ΡΠ΅Π½ΠΈΡ ΠΏΠΎ slug."""
|
|
51
|
+
async with db.session() as session:
|
|
52
|
+
stmt = (
|
|
53
|
+
select(Topic)
|
|
54
|
+
.where(Topic.slug == slug)
|
|
55
|
+
.options(selectinload(Topic.messages))
|
|
56
|
+
)
|
|
57
|
+
res = await session.execute(stmt)
|
|
58
|
+
topic = res.scalar_one_or_none()
|
|
59
|
+
if not topic:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
breadcrumbs = await get_topic_breadcrumbs(session, topic.id)
|
|
63
|
+
discussion_ld = await get_topic_discussion_posting(session, topic.id)
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
"id": topic.id,
|
|
67
|
+
"title": topic.title,
|
|
68
|
+
"slug": topic.slug,
|
|
69
|
+
"is_category": topic.is_category,
|
|
70
|
+
"seo_title": topic.seo_title,
|
|
71
|
+
"seo_description": topic.seo_description,
|
|
72
|
+
"breadcrumbs": breadcrumbs,
|
|
73
|
+
"json_ld": discussion_ld,
|
|
74
|
+
"messages": [
|
|
75
|
+
{
|
|
76
|
+
"id": m.id,
|
|
77
|
+
"author_name": m.author_name,
|
|
78
|
+
"content": m.content,
|
|
79
|
+
"created_at": m.created_at.isoformat(),
|
|
80
|
+
}
|
|
81
|
+
for m in topic.messages
|
|
82
|
+
],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@router.rpc_method(role=["user", "admin"])
|
|
86
|
+
async def create_message(
|
|
87
|
+
session_data: SessionData,
|
|
88
|
+
topic_id: int,
|
|
89
|
+
content: str,
|
|
90
|
+
author_name: str | None = None,
|
|
91
|
+
) -> dict[str, Any]:
|
|
92
|
+
"""Π‘ΠΎΠ·Π΄Π°Π΅Ρ Π½ΠΎΠ²ΠΎΠ΅ ΡΠΎΠΎΠ±ΡΠ΅Π½ΠΈΠ΅ Π² ΡΠ΅ΠΌΠ΅ (Π΄ΠΎΡΡΡΠΏΠ½ΠΎ Π°Π²ΡΠΎΡΠΈΠ·ΠΎΠ²Π°Π½Π½ΡΠΌ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»ΡΠΌ)."""
|
|
93
|
+
name = author_name or session_data.username or "ΠΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Ρ"
|
|
94
|
+
async with db.session() as session:
|
|
95
|
+
msg = Message(
|
|
96
|
+
topic_id=topic_id,
|
|
97
|
+
author_name=name,
|
|
98
|
+
content=content,
|
|
99
|
+
)
|
|
100
|
+
session.add(msg)
|
|
101
|
+
await session.commit()
|
|
102
|
+
return {
|
|
103
|
+
"id": msg.id,
|
|
104
|
+
"topic_id": msg.topic_id,
|
|
105
|
+
"author_name": msg.author_name,
|
|
106
|
+
"content": msg.content,
|
|
107
|
+
"created_at": msg.created_at.isoformat(),
|
|
108
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ΠΠ΅ΠΌΠΎ-ΡΠΈΠ΄Π΅Ρ Π΄Π»Ρ ΡΠΎΡΡΠΌΠ°: Π½Π°ΠΏΠΎΠ»Π½Π΅Π½ΠΈΠ΅ ΡΠ°Π·Π΄Π΅Π»Π°ΠΌΠΈ ΠΈ ΡΠΎΠΎΠ±ΡΠ΅Π½ΠΈΡΠΌΠΈ.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import select
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
|
|
12
|
+
from pyferro_demo.forum.models import Message, Topic
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def seed_forum_data(session: AsyncSession) -> dict[str, int]:
|
|
16
|
+
"""
|
|
17
|
+
ΠΠ΄Π΅ΠΌΠΏΠΎΡΠ΅Π½ΡΠ½ΠΎ Π·Π°ΠΏΠΎΠ»Π½ΡΠ΅Ρ Π±Π°Π·Ρ Π΄Π°Π½Π½ΡΡ
ΡΠ΅ΠΌΠ°ΠΌΠΈ ΠΈ ΡΠΎΠΎΠ±ΡΠ΅Π½ΠΈΡΠΌΠΈ, Π΅ΡΠ»ΠΈ ΠΎΠ½Π° ΠΏΡΡΡΠ°.
|
|
18
|
+
"""
|
|
19
|
+
res = await session.execute(select(Topic))
|
|
20
|
+
if res.scalars().first() is not None:
|
|
21
|
+
return {"topics": 0, "messages": 0}
|
|
22
|
+
|
|
23
|
+
now = datetime.now(timezone.utc)
|
|
24
|
+
|
|
25
|
+
# 1. ΠΠΎΡΠ½Π΅Π²ΠΎΠΉ ΡΠ°Π·Π΄Π΅Π» 1
|
|
26
|
+
t1 = Topic(
|
|
27
|
+
title="ΠΠ±ΡΠΈΠ΅ Π΄ΠΈΡΠΊΡΡΡΠΈΠΈ",
|
|
28
|
+
slug="general",
|
|
29
|
+
is_category=True,
|
|
30
|
+
seo_title="ΠΠ±ΡΠΈΠ΅ ΡΠ΅ΠΌΡ ΠΈ Π½ΠΎΠ²ΠΎΡΡΠΈ ΡΠΎΠΎΠ±ΡΠ΅ΡΡΠ²Π° PyFerro",
|
|
31
|
+
seo_description="ΠΠΎΡΠ½Π΅Π²ΠΎΠΉ ΡΠ°Π·Π΄Π΅Π» ΠΎΠ±ΡΠΈΡ
ΠΎΠ±ΡΡΠΆΠ΄Π΅Π½ΠΈΠΉ, Π°Π½ΠΎΠ½ΡΠΎΠ² ΠΈ ΠΏΡΠ°Π²ΠΈΠ» ΠΏΡΠΎΠ΅ΠΊΡΠ° PyFerro.",
|
|
32
|
+
created_at=now - timedelta(days=10),
|
|
33
|
+
)
|
|
34
|
+
session.add(t1)
|
|
35
|
+
await session.flush()
|
|
36
|
+
|
|
37
|
+
# 2. ΠΠΎΠ΄ΡΠ°Π·Π΄Π΅Π» Π½ΠΎΠ²ΠΎΡΡΠ΅ΠΉ (Π²Π½ΡΡΡΠΈ 1)
|
|
38
|
+
t2 = Topic(
|
|
39
|
+
parent_id=t1.id,
|
|
40
|
+
title="ΠΠΎΠ²ΠΎΡΡΠΈ ΠΈ Π°Π½ΠΎΠ½ΡΡ",
|
|
41
|
+
slug="news",
|
|
42
|
+
is_category=True,
|
|
43
|
+
seo_title="ΠΡΠΈΡΠΈΠ°Π»ΡΠ½ΡΠ΅ Π½ΠΎΠ²ΠΎΡΡΠΈ ΠΈ ΡΠ΅Π»ΠΈΠ·Ρ",
|
|
44
|
+
seo_description="ΠΡΠ΅ ΠΎΡΠΈΡΠΈΠ°Π»ΡΠ½ΡΠ΅ Π½ΠΎΠ²ΠΎΡΡΠΈ ΡΡΠ΅ΠΉΠΌΠ²ΠΎΡΠΊΠ°, ΡΠ΅Π»ΠΈΠ·-Π½ΠΎΡΡ ΠΈ Π²Π°ΠΆΠ½ΡΠ΅ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΡ.",
|
|
45
|
+
created_at=now - timedelta(days=9),
|
|
46
|
+
)
|
|
47
|
+
session.add(t2)
|
|
48
|
+
await session.flush()
|
|
49
|
+
|
|
50
|
+
# 3. ΠΠ΅ΡΠΊΠ° ΠΎΠ±ΡΡΠΆΠ΄Π΅Π½ΠΈΡ ΡΠ΅Π»ΠΈΠ·Π° (Π²Π½ΡΡΡΠΈ 2)
|
|
51
|
+
t3 = Topic(
|
|
52
|
+
parent_id=t2.id,
|
|
53
|
+
title="Π Π΅Π»ΠΈΠ· PyFerro 2.0: Π°ΡΡ
ΠΈΡΠ΅ΠΊΡΡΡΠ° ΠΈ Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΡΡΠΈ",
|
|
54
|
+
slug="pyferro-2-0-release",
|
|
55
|
+
is_category=False,
|
|
56
|
+
seo_title="ΠΠ±ΡΡΠΆΠ΄Π΅Π½ΠΈΠ΅ ΡΠ΅Π»ΠΈΠ·Π° PyFerro 2.0",
|
|
57
|
+
seo_description="ΠΠΎΠ΄ΡΠΎΠ±Π½ΡΠΉ ΡΠ°Π·Π±ΠΎΡ Π°ΡΡ
ΠΈΡΠ΅ΠΊΡΡΡΡ PyFerro 2.0: Rust-ΡΠ΅ΡΠ²Π΅Ρ Granian, RSGI, JSON-RPC ΠΈ Svelte 5.",
|
|
58
|
+
created_at=now - timedelta(days=8),
|
|
59
|
+
)
|
|
60
|
+
session.add(t3)
|
|
61
|
+
|
|
62
|
+
# 4. ΠΠ΅ΡΠΊΠ° Π΄ΠΎΡΠΎΠΆΠ½ΠΎΠΉ ΠΊΠ°ΡΡΡ (Π²Π½ΡΡΡΠΈ 2)
|
|
63
|
+
t4 = Topic(
|
|
64
|
+
parent_id=t2.id,
|
|
65
|
+
title="ΠΠΎΡΠΎΠΆΠ½Π°Ρ ΠΊΠ°ΡΡΠ° ΡΠ°Π·Π²ΠΈΡΠΈΡ Π½Π° 2026 Π³ΠΎΠ΄",
|
|
66
|
+
slug="roadmap-2026",
|
|
67
|
+
is_category=False,
|
|
68
|
+
seo_title="Roadmap PyFerro 2026",
|
|
69
|
+
seo_description="ΠΠ»Π°Π½Ρ ΡΠ°Π·Π²ΠΈΡΠΈΡ ΠΏΠ»Π°ΡΡΠΎΡΠΌΡ: ΠΏΠΎΠ΄Π΄Π΅ΡΠΆΠΊΠ° Π½ΠΎΠ²ΡΡ
ΠΏΡΠΎΡΠΎΠΊΠΎΠ»ΠΎΠ², ΠΏΠ»Π°Π³ΠΈΠ½Ρ ΠΈ ΠΌΠ°ΡΡΡΠ°Π±ΠΈΡΠΎΠ²Π°Π½ΠΈΠ΅.",
|
|
70
|
+
created_at=now - timedelta(days=7),
|
|
71
|
+
)
|
|
72
|
+
session.add(t4)
|
|
73
|
+
|
|
74
|
+
# 5. Π’Π΅ΠΌΠ° ΠΏΡΠ°Π²ΠΈΠ» (Π²Π½ΡΡΡΠΈ 1)
|
|
75
|
+
t5 = Topic(
|
|
76
|
+
parent_id=t1.id,
|
|
77
|
+
title="ΠΡΠ°Π²ΠΈΠ»Π° ΡΠΎΡΡΠΌΠ° ΠΈ ΠΎΡΠΎΡΠΌΠ»Π΅Π½ΠΈΡ ΠΊΠΎΠ΄Π°",
|
|
78
|
+
slug="rules",
|
|
79
|
+
is_category=False,
|
|
80
|
+
seo_title="ΠΡΠ°Π²ΠΈΠ»Π° ΡΠΎΠΎΠ±ΡΠ΅ΡΡΠ²Π° PyFerro",
|
|
81
|
+
seo_description="Π Π΅ΠΊΠΎΠΌΠ΅Π½Π΄Π°ΡΠΈΠΈ ΠΏΠΎ ΡΠΎΠ·Π΄Π°Π½ΠΈΡ Π²Π΅ΡΠΎΠΊ, ΠΊΡΠ»ΡΡΡΡΠ΅ ΠΎΠ±ΡΠ΅Π½ΠΈΡ ΠΈ ΡΠΎΡΠΌΠ°ΡΠΈΡΠΎΠ²Π°Π½ΠΈΡ ΡΠ½ΠΈΠΏΠΏΠ΅ΡΠΎΠ² ΠΊΠΎΠ΄Π°.",
|
|
82
|
+
created_at=now - timedelta(days=6),
|
|
83
|
+
)
|
|
84
|
+
session.add(t5)
|
|
85
|
+
await session.flush()
|
|
86
|
+
|
|
87
|
+
# 6. ΠΠΎΡΠ½Π΅Π²ΠΎΠΉ ΡΠ°Π·Π΄Π΅Π» 2: Π Π°Π·ΡΠ°Π±ΠΎΡΠΊΠ°
|
|
88
|
+
t6 = Topic(
|
|
89
|
+
title="Π Π°Π·ΡΠ°Π±ΠΎΡΠΊΠ° ΠΈ Π°ΡΡ
ΠΈΡΠ΅ΠΊΡΡΡΠ°",
|
|
90
|
+
slug="development",
|
|
91
|
+
is_category=True,
|
|
92
|
+
seo_title="Π Π°Π·ΡΠ°Π±ΠΎΡΠΊΠ° Π±ΡΠΊΠ΅Π½Π΄Π° ΠΈ ΠΈΠ½ΡΠ΅ΡΡΠ΅ΠΉΡΠΎΠ²",
|
|
93
|
+
seo_description="Π’Π΅Ρ
Π½ΠΈΡΠ΅ΡΠΊΠΈΠΉ ΡΠ°Π·Π΄Π΅Π»: Π°ΡΡ
ΠΈΡΠ΅ΠΊΡΡΡΠ° ΡΠ΅ΡΠ²ΠΈΡΠΎΠ², Π±Π°Π·Ρ Π΄Π°Π½Π½ΡΡ
, API ΠΈ ΡΡΠΎΠ½ΡΠ΅Π½Π΄.",
|
|
94
|
+
created_at=now - timedelta(days=5),
|
|
95
|
+
)
|
|
96
|
+
session.add(t6)
|
|
97
|
+
await session.flush()
|
|
98
|
+
|
|
99
|
+
# 7. ΠΠΎΠ΄ΡΠ°Π·Π΄Π΅Π» Π±ΡΠΊΠ΅Π½Π΄Π° (Π²Π½ΡΡΡΠΈ 6)
|
|
100
|
+
t7 = Topic(
|
|
101
|
+
parent_id=t6.id,
|
|
102
|
+
title="ΠΡΠΊΠ΅Π½Π΄ ΠΈ Π±Π°Π·Ρ Π΄Π°Π½Π½ΡΡ
",
|
|
103
|
+
slug="backend-db",
|
|
104
|
+
is_category=True,
|
|
105
|
+
seo_title="ΠΡΠΊΠ΅Π½Π΄ Π½Π° Rust RSGI ΠΈ SQLAlchemy",
|
|
106
|
+
seo_description="ΠΠ±ΡΡΠΆΠ΄Π΅Π½ΠΈΠ΅ Π°ΡΠΈΠ½Ρ
ΡΠΎΠ½Π½ΠΎΠ³ΠΎ Π΄Π²ΠΈΠΆΠΊΠ°, ΠΌΠΎΠ΄Π΅Π»Π΅ΠΉ SQLAlchemy ΠΈ ΠΏΡΠΎΠΈΠ·Π²ΠΎΠ΄ΠΈΡΠ΅Π»ΡΠ½ΠΎΡΡΠΈ.",
|
|
107
|
+
created_at=now - timedelta(days=4),
|
|
108
|
+
)
|
|
109
|
+
session.add(t7)
|
|
110
|
+
await session.flush()
|
|
111
|
+
|
|
112
|
+
# 8. Π’ΠΎΠΏΠΈΠΊ ΠΎ ΠΏΡΠΎΠΈΠ·Π²ΠΎΠ΄ΠΈΡΠ΅Π»ΡΠ½ΠΎΡΡΠΈ Granian (Π²Π½ΡΡΡΠΈ 7)
|
|
113
|
+
t8 = Topic(
|
|
114
|
+
parent_id=t7.id,
|
|
115
|
+
title="ΠΠ΅Π½ΡΠΌΠ°ΡΠΊΠΈ Granian RSGI ΠΏΡΠΎΡΠΈΠ² Uvicorn",
|
|
116
|
+
slug="granian-vs-uvicorn",
|
|
117
|
+
is_category=False,
|
|
118
|
+
seo_title="Π‘ΡΠ°Π²Π½Π΅Π½ΠΈΠ΅ ΠΏΡΠΎΠΈΠ·Π²ΠΎΠ΄ΠΈΡΠ΅Π»ΡΠ½ΠΎΡΡΠΈ Granian ΠΈ Uvicorn",
|
|
119
|
+
seo_description="Π’Π΅ΡΡΡ RPS ΠΈ Π·Π°Π΄Π΅ΡΠΆΠ΅ΠΊ: ΠΊΠ°ΠΊ Rust-ΡΠ°Π½ΡΠ°ΠΉΠΌ Granian ΡΡΠΊΠΎΡΡΠ΅Ρ Π°ΡΠΈΠ½Ρ
ΡΠΎΠ½Π½ΡΠΉ Python.",
|
|
120
|
+
created_at=now - timedelta(days=3),
|
|
121
|
+
)
|
|
122
|
+
session.add(t8)
|
|
123
|
+
|
|
124
|
+
# 9. Π’ΠΎΠΏΠΈΠΊ ΠΎ ΡΠΆΠ°ΡΠΈΠΈ Tabular RPC (Π²Π½ΡΡΡΠΈ 7)
|
|
125
|
+
t9 = Topic(
|
|
126
|
+
parent_id=t7.id,
|
|
127
|
+
title="Π‘ΠΆΠ°ΡΠΈΠ΅ Π΄Π°Π½Π½ΡΡ
Tabular: ΡΠΊΠΎΠ½ΠΎΠΌΠΈΡ ΡΠ΅ΡΠ΅Π²ΠΎΠ³ΠΎ ΡΡΠ°ΡΠΈΠΊΠ° Π΄ΠΎ 80%",
|
|
128
|
+
slug="tabular-compression",
|
|
129
|
+
is_category=False,
|
|
130
|
+
seo_title="ΠΠΊΠΎΠ½ΠΎΠΌΠΈΡ ΡΡΠ°ΡΠΈΠΊΠ° Ρ Tabular RPC",
|
|
131
|
+
seo_description="Π Π°Π·Π±ΠΎΡ ΠΏΡΠΎΡΠΎΠΊΠΎΠ»Π° Tabular Π΄Π»Ρ ΠΏΠ΅ΡΠ΅Π΄Π°ΡΠΈ ΡΠΏΠΈΡΠΊΠΎΠ² ΡΠ»ΠΎΠ²Π°ΡΠ΅ΠΉ ΡΠ΅ΡΠ΅Π· WebSocket.",
|
|
132
|
+
created_at=now - timedelta(days=2),
|
|
133
|
+
)
|
|
134
|
+
session.add(t9)
|
|
135
|
+
|
|
136
|
+
# 10. Π’ΠΎΠΏΠΈΠΊ ΠΎ SEO ΠΈ Dynamic Rendering (Π²Π½ΡΡΡΠΈ 6)
|
|
137
|
+
t10 = Topic(
|
|
138
|
+
parent_id=t6.id,
|
|
139
|
+
title="Dynamic Rendering: ΠΈΠ΄Π΅Π°Π»ΡΠ½Π°Ρ ΠΈΠ½Π΄Π΅ΠΊΡΠ°ΡΠΈΡ SPA Π΄Π»Ρ Google ΠΈ Π―Π½Π΄Π΅ΠΊΡΠ°",
|
|
140
|
+
slug="dynamic-rendering-guide",
|
|
141
|
+
is_category=False,
|
|
142
|
+
seo_title="Π ΡΠΊΠΎΠ²ΠΎΠ΄ΡΡΠ²ΠΎ ΠΏΠΎ Dynamic Rendering Π² PyFerro",
|
|
143
|
+
seo_description="ΠΡΠ°ΠΊΡΠΈΠΊΠ° Π½Π°ΡΡΡΠΎΠΉΠΊΠΈ Jinja2-ΡΠ°Π±Π»ΠΎΠ½ΠΎΠ², IndexNow ΠΈ ΠΊΠ°ΡΡΡ ΡΠ°ΠΉΡΠ° sitemap.xml Π΄Π»Ρ SPA.",
|
|
144
|
+
created_at=now - timedelta(days=1),
|
|
145
|
+
)
|
|
146
|
+
session.add(t10)
|
|
147
|
+
await session.flush()
|
|
148
|
+
|
|
149
|
+
# ΠΠ°ΠΏΠΎΠ»Π½ΡΠ΅ΠΌ ΡΠΎΠΎΠ±ΡΠ΅Π½ΠΈΡΠΌΠΈ
|
|
150
|
+
m1 = Message(
|
|
151
|
+
topic_id=t3.id,
|
|
152
|
+
author_name="ΠΠ»Π΅ΠΊΡΠ΅ΠΉ (ΠΡΡ
ΠΈΡΠ΅ΠΊΡΠΎΡ)",
|
|
153
|
+
content="ΠΠΎΠ»Π»Π΅Π³ΠΈ, ΡΠ°Π΄ ΠΏΡΠ΅Π΄ΡΡΠ°Π²ΠΈΡΡ PyFerro 2.0! ΠΠ»Π°ΡΡΠΎΡΠΌΠ° ΠΏΠ΅ΡΠ΅Π²Π΅Π΄Π΅Π½Π° Π½Π° Rust RSGI (Granian), ΡΡΠΎ ΡΠ½ΠΈΠ·ΠΈΠ»ΠΎ Π·Π°Π΄Π΅ΡΠΆΠΊΠΈ Π΄ΠΎ ΠΌΠΈΠΊΡΠΎΡΠ΅ΠΊΡΠ½Π΄.",
|
|
154
|
+
created_at=now - timedelta(days=8, hours=2),
|
|
155
|
+
)
|
|
156
|
+
m2 = Message(
|
|
157
|
+
topic_id=t3.id,
|
|
158
|
+
author_name="ΠΠΌΠΈΡΡΠΈΠΉ (DevOps)",
|
|
159
|
+
content="ΠΡΠ»ΠΈΡΠ½ΡΠΉ ΡΠ΅Π»ΠΈΠ·! ΠΡΠΎΠ²Π΅ΡΠΈΠ»ΠΈ ΡΠ±ΠΎΡΠΊΡ Docker-ΠΎΠ±ΡΠ°Π·ΠΎΠ² β Π²Π΅Ρ ΠΌΠΈΠ½ΠΈΠΌΠ°Π»ΡΠ½ΡΠΉ, ΠΏΠΎΡΡΠ΅Π±Π»Π΅Π½ΠΈΠ΅ ΠΏΠ°ΠΌΡΡΠΈ ΡΠ½ΠΈΠ·ΠΈΠ»ΠΎΡΡ Π½Π° 40%.",
|
|
160
|
+
created_at=now - timedelta(days=7, hours=18),
|
|
161
|
+
)
|
|
162
|
+
m3 = Message(
|
|
163
|
+
topic_id=t8.id,
|
|
164
|
+
author_name="ΠΠ²Π°Π½ (Senior Python)",
|
|
165
|
+
content="Π ΡΠΈΠ½ΡΠ΅ΡΠΈΡΠ΅ΡΠΊΠΈΡ
ΡΠ΅ΡΡΠ°Ρ
Granian Π΄Π΅ΡΠΆΠΈΡ 45k RPS Π½Π° ΠΎΠ΄Π½ΠΎΠΌ ΡΠ΄ΡΠ΅ ΠΏΡΠΈ Π½ΡΠ»Π΅Π²ΠΎΠΌ Π΄ΡΠΎΠΏΠ΅ ΠΏΠ°ΠΊΠ΅ΡΠΎΠ². ΠΡΠΎ Π»ΡΡΡΠΈΠΉ ΡΠ΅Π·ΡΠ»ΡΡΠ°Ρ Π² Python ΡΠΊΠΎΡΠΈΡΡΠ΅ΠΌΠ΅.",
|
|
166
|
+
created_at=now - timedelta(days=3, hours=1),
|
|
167
|
+
)
|
|
168
|
+
m4 = Message(
|
|
169
|
+
topic_id=t9.id,
|
|
170
|
+
author_name="ΠΠ°ΡΠΈΡ (Frontend)",
|
|
171
|
+
content="Tabular Data Compression ΡΡΠ°Π±ΠΎΡΠ°Π» ΠΎΡΠ»ΠΈΡΠ½ΠΎ Π² Π³ΡΠΈΠ΄Π°Ρ
Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ: Π·Π°Π³ΡΡΠ·ΠΊΠ° 10,000 ΡΡΡΠΎΠΊ ΡΠ΅ΠΏΠ΅ΡΡ Π·Π°Π½ΠΈΠΌΠ°Π΅Ρ ΠΌΠΈΠ»Π»ΠΈΡΠ΅ΠΊΡΠ½Π΄Ρ.",
|
|
172
|
+
created_at=now - timedelta(days=2, hours=3),
|
|
173
|
+
)
|
|
174
|
+
m5 = Message(
|
|
175
|
+
topic_id=t10.id,
|
|
176
|
+
author_name="Π‘Π΅ΡΠ³Π΅ΠΉ (SEO-ΡΠΏΠ΅ΡΠΈΠ°Π»ΠΈΡΡ)",
|
|
177
|
+
content="ΠΠΎΡΡ ΠΏΠΎΠΈΡΠΊΠΎΠ²ΠΈΠΊΠΎΠ² Π²ΠΈΠ΄ΡΡ ΡΠΈΡΡΡΠΉ HTML Ρ ΡΠ°Π·ΠΌΠ΅ΡΠΊΠΎΠΉ Schema.org, Π° ΠΆΠΈΠ²ΡΠ΅ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»ΠΈ ΠΏΠΎΠ»ΡΡΠ°ΡΡ ΡΠ΅Π°ΠΊΡΠΈΠ²Π½ΡΠΉ SPA. ΠΠ΄Π΅Π°Π»ΡΠ½ΡΠΉ Π±Π°Π»Π°Π½Ρ!",
|
|
178
|
+
created_at=now - timedelta(days=1, hours=4),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
session.add_all([m1, m2, m3, m4, m5])
|
|
182
|
+
await session.commit()
|
|
183
|
+
|
|
184
|
+
return {"topics": 10, "messages": 5}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ΠΠ΅Π½Π΅ΡΠ°ΡΠΎΡ SEO-ΠΌΠΈΠΊΡΠΎΡΠ°Π·ΠΌΠ΅ΡΠΊΠΈ Schema.org (BreadcrumbList, DiscussionForumPosting) ΠΈ sitemap.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import select
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
from sqlalchemy.orm import selectinload
|
|
12
|
+
|
|
13
|
+
from pyferro_demo.forum.models import Message, Topic
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def get_topic_breadcrumbs(
|
|
17
|
+
session: AsyncSession, topic_id: int, base_url: str = "http://localhost:8080"
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
"""Π‘ΡΡΠΎΠΈΡ Ρ
Π»Π΅Π±Π½ΡΠ΅ ΠΊΡΠΎΡΠΊΠΈ Schema.org BreadcrumbList."""
|
|
20
|
+
base_url = base_url.rstrip("/")
|
|
21
|
+
chain: list[Topic] = []
|
|
22
|
+
curr_id: int | None = topic_id
|
|
23
|
+
visited: set[int] = set()
|
|
24
|
+
|
|
25
|
+
while curr_id is not None and curr_id not in visited:
|
|
26
|
+
visited.add(curr_id)
|
|
27
|
+
topic = await session.get(Topic, curr_id)
|
|
28
|
+
if not topic:
|
|
29
|
+
break
|
|
30
|
+
chain.append(topic)
|
|
31
|
+
curr_id = topic.parent_id
|
|
32
|
+
|
|
33
|
+
chain.reverse()
|
|
34
|
+
|
|
35
|
+
path_segments: list[str] = []
|
|
36
|
+
items: list[dict[str, Any]] = [
|
|
37
|
+
{
|
|
38
|
+
"@type": "ListItem",
|
|
39
|
+
"position": 1,
|
|
40
|
+
"name": "Π€ΠΎΡΡΠΌ",
|
|
41
|
+
"item": f"{base_url}/forum",
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
for idx, t in enumerate(chain, start=2):
|
|
46
|
+
path_segments.append(t.slug)
|
|
47
|
+
full_url = f"{base_url}/forum/{'/'.join(path_segments)}"
|
|
48
|
+
items.append(
|
|
49
|
+
{
|
|
50
|
+
"@type": "ListItem",
|
|
51
|
+
"position": idx,
|
|
52
|
+
"name": t.title,
|
|
53
|
+
"item": full_url,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"@context": "https://schema.org",
|
|
59
|
+
"@type": "BreadcrumbList",
|
|
60
|
+
"itemListElement": items,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def get_topic_discussion_posting(
|
|
65
|
+
session: AsyncSession, topic_id: int, base_url: str = "http://localhost:8080"
|
|
66
|
+
) -> dict[str, Any] | None:
|
|
67
|
+
"""ΠΠ΅Π½Π΅ΡΠΈΡΡΠ΅Ρ ΠΌΠΈΠΊΡΠΎΡΠ°Π·ΠΌΠ΅ΡΠΊΡ DiscussionForumPosting Π΄Π»Ρ ΡΠ½ΠΈΠΏΠΏΠ΅ΡΠΎΠ² ΠΏΠΎΠΈΡΠΊΠΎΠ²ΠΈΠΊΠΎΠ²."""
|
|
68
|
+
base_url = base_url.rstrip("/")
|
|
69
|
+
stmt = (
|
|
70
|
+
select(Topic)
|
|
71
|
+
.where(Topic.id == topic_id)
|
|
72
|
+
.options(selectinload(Topic.messages))
|
|
73
|
+
)
|
|
74
|
+
result = await session.execute(stmt)
|
|
75
|
+
topic = result.scalar_one_or_none()
|
|
76
|
+
|
|
77
|
+
if not topic:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
messages = sorted(topic.messages, key=lambda m: m.created_at)
|
|
81
|
+
first_post = messages[0] if messages else None
|
|
82
|
+
replies = messages[1:] if len(messages) > 1 else []
|
|
83
|
+
|
|
84
|
+
url = f"{base_url}/forum/{topic.slug}"
|
|
85
|
+
|
|
86
|
+
ld: dict[str, Any] = {
|
|
87
|
+
"@context": "https://schema.org",
|
|
88
|
+
"@type": "DiscussionForumPosting",
|
|
89
|
+
"headline": topic.seo_title or topic.title,
|
|
90
|
+
"description": topic.seo_description or topic.title,
|
|
91
|
+
"url": url,
|
|
92
|
+
"datePublished": topic.created_at.isoformat(),
|
|
93
|
+
"dateModified": topic.updated_at.isoformat(),
|
|
94
|
+
"interactionStatistic": {
|
|
95
|
+
"@type": "InteractionCounter",
|
|
96
|
+
"interactionType": "https://schema.org/CommentAction",
|
|
97
|
+
"userInteractionCount": len(topic.messages),
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if first_post:
|
|
102
|
+
ld["author"] = {
|
|
103
|
+
"@type": "Person",
|
|
104
|
+
"name": first_post.author_name,
|
|
105
|
+
}
|
|
106
|
+
ld["articleBody"] = first_post.content
|
|
107
|
+
|
|
108
|
+
if replies:
|
|
109
|
+
ld["comment"] = [
|
|
110
|
+
{
|
|
111
|
+
"@type": "Comment",
|
|
112
|
+
"text": rep.content,
|
|
113
|
+
"dateCreated": rep.created_at.isoformat(),
|
|
114
|
+
"author": {
|
|
115
|
+
"@type": "Person",
|
|
116
|
+
"name": rep.author_name,
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
for rep in replies
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
return ld
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyferro-demo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Showcase & educational demo application built on the PyFerro framework (SEO Forum, JSON-RPC, Granian RSGI).
|
|
5
|
+
Author-email: Alex Titoff <a.tit.off@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/atitoff-dotcom/PyFerro
|
|
8
|
+
Project-URL: Repository, https://github.com/atitoff-dotcom/PyFerro.git
|
|
9
|
+
Keywords: pyferro,demo,forum,showcase,rsgi,wsrpc
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: pyferro>=2.1.0
|
|
13
|
+
|
|
14
|
+
# π PyFerro Showcase & Demo Application
|
|
15
|
+
|
|
16
|
+
ΠΡΠΈΡΠΈΠ°Π»ΡΠ½ΠΎΠ΅ Π΄Π΅ΠΌΠΎΠ½ΡΡΡΠ°ΡΠΈΠΎΠ½Π½ΠΎΠ΅ ΠΈ ΠΎΠ±ΡΡΠ°ΡΡΠ΅Π΅ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ Π½Π° ΡΠ΅Π°ΠΊΡΠΈΠ²Π½ΠΎΠΌ ΡΡΠ΅ΠΉΠΌΠ²ΠΎΡΠΊΠ΅ **PyFerro**.
|
|
17
|
+
Π‘Π»ΡΠΆΠΈΡ ΠΎΠ±ΡΠ°Π·ΡΠΎΠΌ Π°ΡΡ
ΠΈΡΠ΅ΠΊΡΡΡΡ, ΠΆΠΈΠ²ΡΠΌ ΠΏΡΠΈΠΌΠ΅ΡΠΎΠΌ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°Π½ΠΈΡ ΠΈ E2E-ΡΠ΅ΡΡΠ΅ΡΠΎΠΌ ΡΠ΄ΡΠ°.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## π‘ ΠΡΠΎΠ±Π΅Π½Π½ΠΎΡΡΠΈ
|
|
22
|
+
|
|
23
|
+
- **ΠΠ·ΠΎΠ»ΠΈΡΠΎΠ²Π°Π½Π½Π°Ρ Π±Π°Π·Π° Π΄Π°Π½Π½ΡΡ
:** ΠΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΏΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ ΡΠΎΠ·Π΄Π°Π΅Ρ ΠΈ ΠΈΡΠΏΠΎΠ»ΡΠ·ΡΠ΅Ρ Π»ΠΎΠΊΠ°Π»ΡΠ½ΡΡ Π±Π°Π·Ρ Π΄Π°Π½Π½ΡΡ
`demo.db` (SQLite). ΠΠ°ΡΠ° ΠΊΠΎΡΠΏΠΎΡΠ°ΡΠΈΠ²Π½Π°Ρ ΠΠ ΠΎΡΡΠ°Π΅ΡΡΡ Π² ΠΏΠΎΠ»Π½ΠΎΠΉ Π±Π΅Π·ΠΎΠΏΠ°ΡΠ½ΠΎΡΡΠΈ.
|
|
24
|
+
- **ΠΠΎΠ»Π½ΠΎΡΡΠ½ΠΊΡΠΈΠΎΠ½Π°Π»ΡΠ½ΡΠΉ SEO-ΡΠΎΡΡΠΌ:**
|
|
25
|
+
- ΠΡΠ΅Π²ΠΎΠ²ΠΈΠ΄Π½Π°Ρ ΡΡΡΡΠΊΡΡΡΠ° ΡΠ°Π·Π΄Π΅Π»ΠΎΠ² ΠΈ Π²Π΅ΡΠΎΠΊ (`Topic`, `Message`).
|
|
26
|
+
- ΠΠΈΠΊΡΠΎΡΠ°Π·ΠΌΠ΅ΡΠΊΠ° Schema.org (`BreadcrumbList`, `DiscussionForumPosting` Π² JSON-LD).
|
|
27
|
+
- ΠΠΈΠ½Π°ΠΌΠΈΡΠ΅ΡΠΊΠΈΠΉ ΡΠ΅Π½Π΄Π΅ΡΠΈΠ½Π³ ΡΡΡΠ°Π½ΠΈΡ Π΄Π»Ρ ΠΏΠΎΠΈΡΠΊΠΎΠ²ΡΡ
Π±ΠΎΡΠΎΠ² (Jinja2).
|
|
28
|
+
- **Π‘ΠΈΠΌΠΌΠ΅ΡΡΠΈΡΠ½ΡΠΉ JSON-RPC 2.0:** ΠΡΠΈΠΌΠ΅ΡΡ Π΄Π΅ΠΊΠ»Π°ΡΠ°ΡΠΈΠΈ ΠΌΠ΅ΡΠΎΠ΄ΠΎΠ² Ρ ΠΊΠΎΠ½ΡΡΠΎΠ»Π΅ΠΌ ΡΠΎΠ»Π΅ΠΉ (RBAC).
|
|
29
|
+
- **ΠΠΎΡΠΎΠ²ΡΠ΅ ΡΠΈΠ΄-Π΄Π°Π½Π½ΡΠ΅:** ΠΠ²ΡΠΎΠΌΠ°ΡΠΈΡΠ΅ΡΠΊΠΎΠ΅ Π½Π°ΠΏΠΎΠ»Π½Π΅Π½ΠΈΠ΅ 10 ΡΠ΅ΠΌΠ°ΠΌΠΈ ΠΈ Π²Π΅ΡΠΊΠ°ΠΌΠΈ ΠΎΠ±ΡΡΠΆΠ΄Π΅Π½ΠΈΠΉ ΠΏΡΠΈ ΠΏΠ΅ΡΠ²ΠΎΠΌ Π·Π°ΠΏΡΡΠΊΠ΅.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## β‘ ΠΡΡΡΡΡΠΉ ΡΡΠ°ΡΡ
|
|
34
|
+
|
|
35
|
+
### 1. Π£ΡΡΠ°Π½ΠΎΠ²ΠΊΠ° ΠΈΠ· PyPI:
|
|
36
|
+
```bash
|
|
37
|
+
pip install pyferro-demo
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 2. ΠΠ°ΠΏΡΡΠΊ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ:
|
|
41
|
+
```bash
|
|
42
|
+
pyferro-demo run
|
|
43
|
+
```
|
|
44
|
+
ΠΈΠ»ΠΈ Π±Π΅Π· ΠΏΡΠ΅Π΄Π²Π°ΡΠΈΡΠ΅Π»ΡΠ½ΠΎΠΉ ΡΡΡΠ°Π½ΠΎΠ²ΠΊΠΈ:
|
|
45
|
+
```bash
|
|
46
|
+
pipx run pyferro-demo
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
ΠΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ Π·Π°ΠΏΡΡΡΠΈΡΡΡ Π½Π° Rust-ΡΠ΅ΡΠ²Π΅ΡΠ΅ Granian:
|
|
50
|
+
- **ΠΠ΄ΠΌΠΈΠ½ΠΊΠ° PyFerro:** [http://localhost:8080/pyferro_admin/](http://localhost:8080/pyferro_admin/) (Π»ΠΎΠ³ΠΈΠ½: `admin`, ΠΏΠ°ΡΠΎΠ»Ρ: `admin`)
|
|
51
|
+
- **JSON-RPC ΡΠ½Π΄ΠΏΠΎΠΈΠ½Ρ:** `ws://localhost:8080/wsrpc`
|
|
52
|
+
- **Sitemap XML:** [http://localhost:8080/sitemap.xml](http://localhost:8080/sitemap.xml)
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## π Π‘ΡΡΡΠΊΡΡΡΠ° ΠΏΡΠΎΠ΅ΠΊΡΠ°
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
pyferro_demo/
|
|
60
|
+
βββ cli.py # Π’ΠΎΡΠΊΠ° Π²Ρ
ΠΎΠ΄Π° ΠΊΠΎΠ½ΡΠΎΠ»ΡΠ½ΠΎΠΉ ΠΊΠΎΠΌΠ°Π½Π΄Ρ pyferro-demo
|
|
61
|
+
βββ app.py # Π€Π°Π±ΡΠΈΠΊΠ° ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ PyFerroApp ΠΈ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΡ ΠΌΠΎΠ΄ΡΠ»Π΅ΠΉ
|
|
62
|
+
βββ forum/
|
|
63
|
+
β βββ models.py # SQLAlchemy-ΠΌΠΎΠ΄Π΅Π»ΠΈ ΡΠΎΠΏΠΈΠΊΠΎΠ² ΠΈ ΡΠΎΠΎΠ±ΡΠ΅Π½ΠΈΠΉ
|
|
64
|
+
β βββ rpc.py # JSON-RPC 2.0 ΠΌΠ΅ΡΠΎΠ΄Ρ (forum.list_topics, forum.get_topic ΠΈ Ρ.Π΄.)
|
|
65
|
+
β βββ seeder.py # ΠΠ΄Π΅ΠΌΠΏΠΎΡΠ΅Π½ΡΠ½ΡΠΉ ΡΠΈΠ΄ΠΈΠ½Π³ Π΄Π΅ΠΌΠΎΠ½ΡΡΡΠ°ΡΠΈΠΎΠ½Π½ΡΡ
Π΄Π°Π½Π½ΡΡ
|
|
66
|
+
β βββ seo.py # ΠΠ΅Π½Π΅ΡΠ°ΡΠΈΡ JSON-LD ΠΌΠΈΠΊΡΠΎΡΠ°Π·ΠΌΠ΅ΡΠΊΠΈ ΠΈ Ρ
Π»Π΅Π±Π½ΡΡ
ΠΊΡΠΎΡΠ΅ΠΊ
|
|
67
|
+
βββ templates/
|
|
68
|
+
βββ forum_topic.html # Π¨Π°Π±Π»ΠΎΠ½ Jinja2 Π΄Π»Ρ Dynamic Rendering
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## π§ͺ ΠΠ°ΠΏΡΡΠΊ ΡΠ΅ΡΡΠΎΠ²
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pytest
|
|
77
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/pyferro_demo/__init__.py
|
|
4
|
+
src/pyferro_demo/__main__.py
|
|
5
|
+
src/pyferro_demo/app.py
|
|
6
|
+
src/pyferro_demo/cli.py
|
|
7
|
+
src/pyferro_demo.egg-info/PKG-INFO
|
|
8
|
+
src/pyferro_demo.egg-info/SOURCES.txt
|
|
9
|
+
src/pyferro_demo.egg-info/dependency_links.txt
|
|
10
|
+
src/pyferro_demo.egg-info/entry_points.txt
|
|
11
|
+
src/pyferro_demo.egg-info/requires.txt
|
|
12
|
+
src/pyferro_demo.egg-info/top_level.txt
|
|
13
|
+
src/pyferro_demo/forum/__init__.py
|
|
14
|
+
src/pyferro_demo/forum/models.py
|
|
15
|
+
src/pyferro_demo/forum/rpc.py
|
|
16
|
+
src/pyferro_demo/forum/seeder.py
|
|
17
|
+
src/pyferro_demo/forum/seo.py
|
|
18
|
+
tests/test_forum_e2e.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyferro>=2.1.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyferro_demo
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
E2E ΡΠ΅ΡΡΡ Π΄Π΅ΠΌΠΎΠ½ΡΡΡΠ°ΡΠΈΠΎΠ½Π½ΠΎΠ³ΠΎ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ pyferro-demo.
|
|
3
|
+
ΠΡΠΎΠ²Π΅ΡΡΡΡ ΠΈΠ½ΡΠ΅Π³ΡΠ°ΡΠΈΡ Ρ ΡΠ΄ΡΠΎΠΌ PyFerro, ΡΠ°Π±ΠΎΡΡ ΠΌΠΎΠ΄Π΅Π»Π΅ΠΉ, RPC ΠΈ SEO.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from pyferro2.core.session import Session
|
|
11
|
+
from pyferro2.core.session_data import SessionData
|
|
12
|
+
from pyferro_demo.app import create_demo_app
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
async def app():
|
|
17
|
+
"""Π‘ΠΎΠ·Π΄Π°Π΅Ρ ΡΠ΅ΡΡΠΎΠ²ΠΎΠ΅ Π΄Π΅ΠΌΠΎ-ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ Π² ΠΏΠ°ΠΌΡΡΠΈ."""
|
|
18
|
+
demo_app = await create_demo_app(db_url="sqlite+aiosqlite:///:memory:")
|
|
19
|
+
yield demo_app
|
|
20
|
+
await demo_app.db.close()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestForumE2E:
|
|
24
|
+
@pytest.mark.asyncio
|
|
25
|
+
async def test_list_topics(self, app):
|
|
26
|
+
"""ΠΡΠΎΠ²Π΅ΡΡΠ΅Ρ ΠΏΠΎΠ»ΡΡΠ΅Π½ΠΈΠ΅ ΡΠΏΠΈΡΠΊΠ° ΡΠ΅ΠΌ ΡΠΎΡΡΠΌΠ° ΡΠ΅ΡΠ΅Π· RPC."""
|
|
27
|
+
session = Session(session_id="s-admin", data=SessionData(user_id=1, username="admin", roles=frozenset(["admin"])))
|
|
28
|
+
topics = await app.router.call("forum.list_topics", session)
|
|
29
|
+
assert isinstance(topics, list)
|
|
30
|
+
assert len(topics) >= 2 # ΠΠΎΡΠ½Π΅Π²ΡΠ΅ ΡΠ΅ΠΌΡ
|
|
31
|
+
|
|
32
|
+
@pytest.mark.asyncio
|
|
33
|
+
async def test_get_topic_with_seo(self, app):
|
|
34
|
+
"""ΠΡΠΎΠ²Π΅ΡΡΠ΅Ρ ΠΏΠΎΠ»ΡΡΠ΅Π½ΠΈΠ΅ ΡΠΎΠΏΠΈΠΊΠ° Ρ JSON-LD ΡΠ°Π·ΠΌΠ΅ΡΠΊΠΎΠΉ."""
|
|
35
|
+
session = Session(session_id="s-admin", data=SessionData(user_id=1, username="admin", roles=frozenset(["admin"])))
|
|
36
|
+
topic = await app.router.call("forum.get_topic", session, {"slug": "pyferro-2-0-release"})
|
|
37
|
+
assert topic is not None
|
|
38
|
+
assert topic["slug"] == "pyferro-2-0-release"
|
|
39
|
+
assert len(topic["messages"]) >= 1
|
|
40
|
+
|
|
41
|
+
# ΠΡΠΎΠ²Π΅ΡΠΊΠ° Ρ
Π»Π΅Π±Π½ΡΡ
ΠΊΡΠΎΡΠ΅ΠΊ
|
|
42
|
+
assert "breadcrumbs" in topic
|
|
43
|
+
assert topic["breadcrumbs"]["@type"] == "BreadcrumbList"
|
|
44
|
+
|
|
45
|
+
# ΠΡΠΎΠ²Π΅ΡΠΊΠ° DiscussionForumPosting
|
|
46
|
+
assert "json_ld" in topic
|
|
47
|
+
assert topic["json_ld"]["@type"] == "DiscussionForumPosting"
|
|
48
|
+
assert "headline" in topic["json_ld"]
|
|
49
|
+
|
|
50
|
+
@pytest.mark.asyncio
|
|
51
|
+
async def test_create_message(self, app):
|
|
52
|
+
"""ΠΡΠΎΠ²Π΅ΡΡΠ΅Ρ ΠΎΡΠΏΡΠ°Π²ΠΊΡ ΡΠΎΠΎΠ±ΡΠ΅Π½ΠΈΡ Π² ΡΠ΅ΠΌΡ ΡΠ΅ΡΠ΅Π· RPC."""
|
|
53
|
+
session = Session(session_id="s-user", data=SessionData(user_id=1, username="tester", roles=frozenset(["user"])))
|
|
54
|
+
res = await app.router.call(
|
|
55
|
+
"forum.create_message",
|
|
56
|
+
session,
|
|
57
|
+
{"topic_id": 3, "content": "Π’Π΅ΡΡΠΎΠ²ΠΎΠ΅ ΡΠΎΠΎΠ±ΡΠ΅Π½ΠΈΠ΅ ΠΈΠ· E2E ΡΠ΅ΡΡΠ°"},
|
|
58
|
+
)
|
|
59
|
+
assert res["content"] == "Π’Π΅ΡΡΠΎΠ²ΠΎΠ΅ ΡΠΎΠΎΠ±ΡΠ΅Π½ΠΈΠ΅ ΠΈΠ· E2E ΡΠ΅ΡΡΠ°"
|
|
60
|
+
assert res["author_name"] == "tester"
|