Skip to content

Commit d4153ec

Browse files
committed
update blog post locations SQL type
1 parent 2c56dc9 commit d4153ec

13 files changed

Lines changed: 688 additions & 77 deletions

File tree

.github/copilot-instructions.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,17 @@ HTML routes auto-discover and register all modules in `app/web/html/routes/` via
2727

2828
### Local development
2929

30-
```bash
30+
```shell
3131
# Start DB (Docker Postgres) + seed dummy data
32-
python -m scripts.start_local_postgres
32+
python -m scripts.start_local_postgres --migrate head
3333

3434
# Start app (requires tmux): runs Tailwind watcher + uvicorn with hot-reload
3535
./scripts/run-dev.sh
3636
```
3737

3838
### Running tests
3939

40-
```bash
40+
```shell
4141
# Unit + functional tests only (default, fast)
4242
pytest
4343

@@ -55,15 +55,22 @@ Tests use a **separate Postgres container** (`postgres_test`, port 5433). The ro
5555

5656
### Linting & formatting
5757

58-
```bash
58+
```shell
5959
pre-commit run --all-files
6060
```
6161

6262
### DB migrations
6363

64-
```bash
65-
python -m scripts.alembic revision --autogenerate -m "description"
66-
python -m scripts.alembic upgrade head
64+
Database migrations use alembic to generate and run migrations. When a database migration is needed, use the following to generate a new migration file:
65+
66+
```shell
67+
python -m scripts.alembic migrate -m "description of migration"
68+
```
69+
70+
Note: You'll need a local Postgres instance running to generate the migration against. You can use the same local Postgres instance used for development:
71+
72+
```shell
73+
python -m scripts.start_local_postgres --migrate head
6774
```
6875

6976
## Key Conventions

README.md

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ You'll need to install a few things to get the website running locally. The webs
1515

1616
First, install [uv](https://github.com/astral-sh/uv) for package management. I recommend installing it with the following command:
1717

18-
```bash
18+
```shell
1919
curl -LsSf https://astral.sh/uv/install.sh | sh
2020
```
2121

2222
Then install python environment:
2323

24-
```bash
24+
```shell
2525
# Create a virtual environment
2626
uv python install 3.14
2727
uv venv --python=3.14 venv
@@ -33,15 +33,15 @@ uv pip install -r requirements-dev.txt
3333

3434
Update requirements with:
3535

36-
```bash
36+
```shell
3737
./scripts/update-requirements.sh
3838
```
3939

4040
### Node
4141

4242
First, install the latest versions of npm and node. Then install the node environment:
4343

44-
```bash
44+
```shell
4545
# Install node environment
4646
npm install
4747
```
@@ -58,7 +58,7 @@ Docker and Docker Compose configurations are in `docker_config`.
5858

5959
To start up the local database for development, run:
6060

61-
```bash
61+
```shell
6262
python -m scripts.start_local_postgres
6363
```
6464

@@ -68,7 +68,7 @@ python -m scripts.start_local_postgres
6868

6969
To run the app locally, you can use the following command:
7070

71-
```bash
71+
```shell
7272
./scripts/run-dev.sh
7373
```
7474

@@ -78,7 +78,7 @@ This will start an instance of `tailwind` that will build the CSS and watch for
7878

7979
To run the app in Docker, use the following command:
8080

81-
```bash
81+
```shell
8282
./scripts/deploy.sh
8383
```
8484

@@ -88,13 +88,13 @@ This command assumes that the Postgres database is not already started by `scrip
8888

8989
To stop the app, use:
9090

91-
```bash
91+
```shell
9292
./scripts/deploy.sh --down
9393
```
9494

9595
For more options, use:
9696

97-
```bash
97+
```shell
9898
./scripts/deploy.sh --help
9999
```
100100

@@ -104,13 +104,13 @@ Tests are written in Python with pytest. They include functional tests against t
104104

105105
To run the playwright tests, you'll need to first install the playwright browsers:
106106

107-
```bash
107+
```shell
108108
playwright install
109109
```
110110

111111
To run tests use one of the following commands:
112112

113-
```bash
113+
```shell
114114
# Standard run of functional and unit tests
115115
pytest
116116

@@ -128,13 +128,13 @@ pytest --cov --cov-report=term --cov-report=html
128128

129129
I use pre-commit to run linting and formatting checks before every commit. Install the pre-commit hooks with:
130130

131-
```bash
131+
```shell
132132
pre-commit install
133133
```
134134

135135
To run the checks manually, use:
136136

137-
```bash
137+
```shell
138138
pre-commit run --all-files
139139
```
140140

@@ -148,7 +148,7 @@ Lint checks include (but are not limited to):
148148

149149
To update dependencies, run:
150150

151-
```bash
151+
```shell
152152
# Update Python dependencies
153153
./scripts/update-requirements.sh --python
154154

@@ -187,7 +187,7 @@ Server-side, a cron job runs every 5 minutes to check for new commits on the `re
187187

188188
To deploy the app manually, SSH into the server, go to the project, and use the following command:
189189

190-
```bash
190+
```shell
191191
./scripts/deploy.sh --prod
192192
```
193193

@@ -204,7 +204,7 @@ SSL certificates are generated by the domain registrar and are stored in the `ce
204204

205205
In addition to the SSL certificates, you'll need to generate a Diffie-Hellman key exchange file for nginx. You can generate this file with the following command:
206206

207-
```bash
207+
```shell
208208
sudo openssl dhparam -out /etc/ssl/certs/dhparam.pem 2048
209209
```
210210

app/datastore/db_models.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import sqlalchemy as sa
77
from sqlalchemy import Column, Computed, ForeignKey, Index, String, Table, asc
8-
from sqlalchemy.dialects.postgresql import TSVECTOR
8+
from sqlalchemy.dialects.postgresql import ARRAY, TSVECTOR
99
from sqlalchemy.ext.asyncio import AsyncAttrs
1010
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
1111

@@ -182,8 +182,9 @@ class BlogPostMedia(Base):
182182
Attributes
183183
----------
184184
name: The name of the media, given as the title of the HTML tag.
185-
locations: The location of the file on the filesystem. If multiple versions
186-
of the file are included, they are comma-separated.
185+
locations: The locations of the file on the filesystem. If multiple
186+
versions of the file are included (e.g. webp + original), each
187+
path is a separate element in the list.
187188
188189
"""
189190

@@ -193,15 +194,11 @@ class BlogPostMedia(Base):
193194
blog_post_id: Mapped[BlogPostFK | None]
194195
blog_post: Mapped[BlogPost] = relationship(back_populates="media")
195196
name: Mapped[str]
196-
locations: Mapped[str]
197+
locations: Mapped[list[str]] = mapped_column(ARRAY(String))
197198
media_type: Mapped[str]
198199
position: Mapped[int | None]
199200
created_timestamp: Mapped[DateTimeIndexed]
200201

201-
def locations_to_list(self) -> list[str]:
202-
"""Get the media locations as a list."""
203-
return self.locations.split(",")
204-
205202

206203
class BlogPostComment(Base):
207204
"""Blog post comment model."""

app/services/blog/blog_handler.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -554,14 +554,12 @@ async def save_media_for_blog_post(
554554
name: str,
555555
) -> db_models.BlogPost:
556556
"""Save media for a blog post."""
557-
locations_str, media_type = _save_bp_media(
558-
name=name, blog_post_slug=blog_post.slug, media=media
559-
)
557+
locations, media_type = _save_bp_media(name=name, blog_post_slug=blog_post.slug, media=media)
560558
return await commit_media_to_db(
561559
db=db,
562560
blog_post=blog_post,
563561
name=name,
564-
locations_str=locations_str,
562+
locations=locations,
565563
media_type=media_type,
566564
)
567565

@@ -597,7 +595,7 @@ async def delete_media_from_blog_post(
597595
media = result.scalars().one()
598596
except sqlalchemy.exc.NoResultFound as e:
599597
raise errors.BlogPostMediaNotFoundError from e
600-
media_locations = media.locations_to_list()
598+
media_locations = media.locations
601599
for location in media_locations:
602600
media_handler.del_media_from_path_str(location)
603601
await db.delete(media)
@@ -606,7 +604,7 @@ async def delete_media_from_blog_post(
606604
return blog_post
607605

608606

609-
def _save_bp_media(name: str, blog_post_slug: str, media: UploadFile) -> tuple[str, str]:
607+
def _save_bp_media(name: str, blog_post_slug: str, media: UploadFile) -> tuple[list[str], str]:
610608
file_name = f"{blog_utils.get_slug(name)}--{blog_post_slug}"
611609
return media_handler.upload_blog_media(
612610
media=media,
@@ -619,15 +617,15 @@ async def commit_media_to_db( # noqa: PLR0913 (too-many-arguments)
619617
*,
620618
blog_post: db_models.BlogPost,
621619
name: str,
622-
locations_str: str,
620+
locations: list[str],
623621
media_type: str,
624622
position: int | None = None,
625623
) -> db_models.BlogPost:
626624
"""Commit a blog post media to the database."""
627625
bp_media_object = db_models.BlogPostMedia(
628626
blog_post_id=blog_post.id,
629627
name=name,
630-
locations=locations_str,
628+
locations=locations,
631629
media_type=media_type,
632630
created_timestamp=datetime.now(UTC),
633631
position=position,

app/services/media/media_handler.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,12 @@ async def upload_avatar(pic: UploadFile, name: str) -> str:
6363
return get_path_str_from_static(path)
6464

6565

66-
def upload_blog_media(media: UploadFile, name: str) -> tuple[str, str]:
66+
def upload_blog_media(media: UploadFile, name: str) -> tuple[list[str], str]:
6767
"""Upload a blog media file.
6868
6969
Returns
7070
-------
71-
A tuple of the path string and the media type.
71+
A tuple of the list of path strings and the media type.
7272
7373
See `save_media` for more details.
7474
@@ -77,17 +77,15 @@ def upload_blog_media(media: UploadFile, name: str) -> tuple[str, str]:
7777
return save_media(media, name)
7878

7979

80-
def save_media(media: UploadFile, name: str) -> tuple[str, str]:
80+
def save_media(media: UploadFile, name: str) -> tuple[list[str], str]:
8181
"""Save a media file.
8282
8383
Returns
8484
-------
85-
A tuple of the path string and the media type.
85+
A tuple of the list of path strings and the media type.
8686
87-
The path string is a comma-separated list of paths.
88-
89-
Images save a webp version as well as the original,
90-
if the webp version is smaller.
87+
Images save a webp version as well as the original,
88+
if the webp version is smaller.
9189
9290
"""
9391
media_type = get_media_type_from_file(media)
@@ -99,15 +97,15 @@ def save_media(media: UploadFile, name: str) -> tuple[str, str]:
9997
raise ValueError(msg)
10098

10199

102-
def save_image(name: str, image_file: MediaFileProtocol) -> str:
100+
def save_image(name: str, image_file: MediaFileProtocol) -> list[str]:
103101
"""Save an image, and its webp version."""
104102
og_image_path = BLOG_UPLOAD_FOLDER / _fix_name_suffix(name)
105103

106104
# GIF and SVG don't save properly with pillow
107105
# Webp is already compressed
108106
if og_image_path.suffix.casefold() in {".gif", ".svg", ".webp"}:
109107
og_image_path.write_bytes(image_file.read())
110-
return get_path_str_from_static(og_image_path)
108+
return [get_path_str_from_static(og_image_path)]
111109

112110
try:
113111
pil_save(
@@ -126,7 +124,7 @@ def save_image(name: str, image_file: MediaFileProtocol) -> str:
126124
if compare_image_sizes(og_image_path, webp_image_path):
127125
webp_image_path.unlink()
128126
images = (path for path in (webp_image_path, og_image_path) if path.exists())
129-
return ",".join(get_path_str_from_static(image) for image in images)
127+
return [get_path_str_from_static(image) for image in images]
130128

131129

132130
def _fix_name_suffix(name: str) -> str:
@@ -139,11 +137,11 @@ def _fix_name_suffix(name: str) -> str:
139137
return f"{start}.{mapped_suffix}" if mapped_suffix else name
140138

141139

142-
def save_video(name: str, video: MediaFileProtocol) -> str:
140+
def save_video(name: str, video: MediaFileProtocol) -> list[str]:
143141
"""Save a video."""
144142
video_path = BLOG_UPLOAD_FOLDER / name
145143
video_path.write_bytes(video.read())
146-
return get_path_str_from_static(video_path)
144+
return [get_path_str_from_static(video_path)]
147145

148146

149147
def pil_save(

app/web/html/templates/blog/partials/list_post_media.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ <h2 class="mt-12 mb-8 text-2xl">Uploaded Media:</h2>
5757
</p>
5858
</span>
5959
<hr class="mb-6" />
60-
{% set locations = media_item.locations_to_list() %}
60+
{% set locations = media_item.locations %}
6161
{% if media_item.media_type == "image" and locations.__len__() == 1 %}
6262
<span
6363
id="media-html-{{ loop.index }}"

0 commit comments

Comments
 (0)