Merge pull request #1 from nickcoad/feature/add-title-and-description-to-posts

Feature/add title and description to posts
This commit is contained in:
Nick Coad 2024-12-04 09:37:30 +11:00 committed by GitHub
commit 2a7a73a00e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 214 additions and 15 deletions

2
.gitignore vendored
View file

@ -13,3 +13,5 @@ server/**/lib/
server/**/bin/ server/**/bin/
server/**/pyvenv.cfg server/**/pyvenv.cfg
__pycache__/ __pycache__/
tmp

0
Makefile Normal file
View file

View file

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:lts as builder FROM --platform=$BUILDPLATFORM node:lts AS builder
WORKDIR /opt/app WORKDIR /opt/app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
@ -11,7 +11,7 @@ ARG CLIENT_BUILD_ARGS=""
RUN BASE_URL="__BASEURL__" node build.js --gzip ${CLIENT_BUILD_ARGS} RUN BASE_URL="__BASEURL__" node build.js --gzip ${CLIENT_BUILD_ARGS}
FROM --platform=$BUILDPLATFORM scratch as approot FROM --platform=$BUILDPLATFORM scratch AS approot
COPY docker-start.sh / COPY docker-start.sh /
@ -22,7 +22,7 @@ WORKDIR /var/www
COPY --from=builder /opt/app/public/ . COPY --from=builder /opt/app/public/ .
FROM nginx:alpine as release FROM nginx:alpine AS release
RUN apk --no-cache add dumb-init RUN apk --no-cache add dumb-init
COPY --from=approot / / COPY --from=approot / /

View file

@ -4,6 +4,24 @@
<div class='messages'></div> <div class='messages'></div>
<section class='title'>
<%= ctx.makeTextInput({
text: 'Title',
name: 'title',
placeholder: '',
value: ctx.post.title,
}) %>
</section>
<section class='description'>
<%= ctx.makeTextInput({
text: 'Description',
name: 'description',
placeholder: '',
value: ctx.post.description,
}) %>
</section>
<% if (ctx.enableSafety && ctx.canEditPostSafety) { %> <% if (ctx.enableSafety && ctx.canEditPostSafety) { %>
<section class='safety'> <section class='safety'>
<label>Safety</label> <label>Safety</label>

View file

@ -1,4 +1,5 @@
<div class='readonly-sidebar'> <div class='readonly-sidebar'>
<h1 style='line-height: 1.2em'><%= ctx.post.title %></h1>
<article class='details'> <article class='details'>
<section class='download'> <section class='download'>
<a rel='external' href='<%- ctx.post.contentUrl %>'> <a rel='external' href='<%- ctx.post.contentUrl %>'>

View file

@ -4,9 +4,20 @@
<% for (let post of ctx.response.results) { %> <% for (let post of ctx.response.results) { %>
<li data-post-id='<%= post.id %>'> <li data-post-id='<%= post.id %>'>
<a class='thumbnail-wrapper <%= post.tags.length > 0 ? "tags" : "no-tags" %>' <a class='thumbnail-wrapper <%= post.tags.length > 0 ? "tags" : "no-tags" %>'
title='@<%- post.id %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag.names[0]).join(' ') || 'none' %>' title='<%- post.title %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag.names[0]).join(' ') || 'none' %>'
href='<%= ctx.canViewPosts ? ctx.getPostUrl(post.id, ctx.parameters) : '' %>'> href='<%= ctx.canViewPosts ? ctx.getPostUrl(post.id, ctx.parameters) : '' %>'>
<%= ctx.makeThumbnail(post.thumbnailUrl) %> <%= ctx.makeThumbnail(post.thumbnailUrl) %>
<span class="post-title" style="
position: absolute;
bottom: 2.3em;
left: 0;
padding: .33em .5em;
color: #fff;
line-height: 1.2em;
margin: 0.5em;
font-size: 12px;
background: rgba(0,0,0,0.5);
"><%= post.title %></span>
<span class='type' data-type='<%- post.type %>'> <span class='type' data-type='<%- post.type %>'>
<% if (post.type == 'video' || post.type == 'flash' || post.type == 'animation') { %> <% if (post.type == 'video' || post.type == 'flash' || post.type == 'animation') { %>
<span class='icon'><i class='fa fa-film'></i></span> <span class='icon'><i class='fa fa-film'></i></span>

View file

@ -14,6 +14,7 @@ const EmptyView = require("../views/empty_view.js");
const fields = [ const fields = [
"id", "id",
"thumbnailUrl", "thumbnailUrl",
"title",
"type", "type",
"safety", "safety",
"score", "score",

View file

@ -169,6 +169,12 @@ class PostMainController extends BasePostController {
this._view.sidebarControl.disableForm(); this._view.sidebarControl.disableForm();
this._view.sidebarControl.clearMessages(); this._view.sidebarControl.clearMessages();
const post = e.detail.post; const post = e.detail.post;
if (e.detail.title !== undefined && e.detail.title !== null) {
post.title = e.detail.title;
}
if (e.detail.description !== undefined && e.detail.description !== null) {
post.description = e.detail.description;
}
if (e.detail.safety !== undefined && e.detail.safety !== null) { if (e.detail.safety !== undefined && e.detail.safety !== null) {
post.safety = e.detail.safety; post.safety = e.detail.safety;
} }

View file

@ -55,7 +55,7 @@ class PostEditSidebarControl extends events.EventTarget {
"post-info", "post-info",
"Basic info", "Basic info",
this._hostNode.querySelectorAll( this._hostNode.querySelectorAll(
".safety, .relations, .flags, .post-source" ".title, .description, .safety, .relations, .flags, .post-source"
) )
); );
this._tagsExpander = new ExpanderControl( this._tagsExpander = new ExpanderControl(
@ -398,6 +398,10 @@ class PostEditSidebarControl extends events.EventTarget {
detail: { detail: {
post: this._post, post: this._post,
title: this._titleInputNode.value,
description: this._descriptionInputNode.value,
safety: this._safetyButtonNodes.length safety: this._safetyButtonNodes.length
? Array.from(this._safetyButtonNodes) ? Array.from(this._safetyButtonNodes)
.filter((node) => node.checked)[0] .filter((node) => node.checked)[0]
@ -481,6 +485,14 @@ class PostEditSidebarControl extends events.EventTarget {
return ret; return ret;
} }
get _titleInputNode() {
return this._formNode.querySelector(".title input");
}
get _descriptionInputNode() {
return this._formNode.querySelector(".description input");
}
get _relationsInputNode() { get _relationsInputNode() {
return this._formNode.querySelector(".relations input"); return this._formNode.querySelector(".relations input");
} }

View file

@ -54,6 +54,14 @@ class Post extends events.EventTarget {
return this._user; return this._user;
} }
get title() {
return this._title;
}
get description() {
return this._description;
}
get safety() { get safety() {
return this._safety; return this._safety;
} }
@ -154,6 +162,14 @@ class Post extends events.EventTarget {
this._flags = value; this._flags = value;
} }
set title(value) {
this._title = value;
}
set description(value) {
this._description = value;
}
set safety(value) { set safety(value) {
this._safety = value; this._safety = value;
} }
@ -250,6 +266,12 @@ class Post extends events.EventTarget {
if (anonymous === true) { if (anonymous === true) {
detail.anonymous = true; detail.anonymous = true;
} }
if (this._title !== this._orig._title) {
detail.title = this._title;
}
if (this._description !== this._orig._description) {
detail.description = this._description;
}
if (this._safety !== this._orig._safety) { if (this._safety !== this._orig._safety) {
detail.safety = this._safety; detail.safety = this._safety;
} }
@ -471,6 +493,8 @@ class Post extends events.EventTarget {
_checksumMD5: response.checksumMD5, _checksumMD5: response.checksumMD5,
_creationTime: response.creationTime, _creationTime: response.creationTime,
_user: response.user, _user: response.user,
_title: response.title,
_description: response.description,
_safety: response.safety, _safety: response.safety,
_contentUrl: response.contentUrl, _contentUrl: response.contentUrl,
_fullContentUrl: new URL( _fullContentUrl: new URL(

View file

@ -10,15 +10,16 @@ fi
# Create a dummy container # Create a dummy container
WORKDIR="$(git rev-parse --show-toplevel)/server" WORKDIR="$(git rev-parse --show-toplevel)/server"
IMAGE=$(docker build -q "${WORKDIR}") IMAGE=$(docker build -q "${WORKDIR}")
CONTAINER=$(docker run -d ${IMAGE} tail -f /dev/null) CONTAINER=$(docker run --network=host -d ${IMAGE} tail -f /dev/null)
# Create the migration script # Create the migration script
docker exec -i \ docker exec -i \
-e PYTHONPATH='/opt/app' \ -e PYTHONPATH='/opt/app' \
-e POSTGRES_HOST='x' \ -e POSTGRES_HOST='localhost' \
-e POSTGRES_USER='x' \ -e POSTGRES_PORT='15432' \
-e POSTGRES_PASSWORD='x' \ -e POSTGRES_USER='szuru' \
${CONTAINER} alembic revision -m "$1" -e POSTGRES_PASSWORD='changeme' \
${CONTAINER} alembic revision --autogenerate -m "$1"
# Copy the file over from the container # Copy the file over from the container
docker cp ${CONTAINER}:/opt/app/szurubooru/migrations/versions/ \ docker cp ${CONTAINER}:/opt/app/szurubooru/migrations/versions/ \

48
docker-compose.local.yml Normal file
View file

@ -0,0 +1,48 @@
## Example Docker Compose configuration
##
## Use this as a template to set up docker-compose, or as guide to set up other
## orchestration services
version: '2'
services:
server:
image: szurubooru/server:latest
depends_on:
- sql
environment:
## These should be the names of the dependent containers listed below,
## or FQDNs/IP addresses if these services are running outside of Docker
POSTGRES_HOST: sql
## Credentials for database:
POSTGRES_USER:
POSTGRES_PASSWORD:
## Commented Values are Default:
#POSTGRES_DB: defaults to same as POSTGRES_USER
#POSTGRES_PORT: 5432
#LOG_SQL: 0 (1 for verbose SQL logs)
THREADS:
volumes:
- "${MOUNT_DATA}:/data"
- "./server/config.yaml:/opt/app/config.yaml"
client:
image: szurubooru/client:latest
depends_on:
- server
environment:
BACKEND_HOST: server
BASE_URL:
volumes:
- "${MOUNT_DATA}:/data:ro"
ports:
- "${PORT}:80"
sql:
image: postgres:11-alpine
restart: unless-stopped
environment:
POSTGRES_USER: szuru
POSTGRES_PASSWORD: changeme
volumes:
- "${MOUNT_SQL}:/var/lib/postgresql/data"

View file

@ -2,8 +2,6 @@
## ##
## Use this as a template to set up docker-compose, or as guide to set up other ## Use this as a template to set up docker-compose, or as guide to set up other
## orchestration services ## orchestration services
version: '2'
services: services:
server: server:
@ -15,19 +13,25 @@ services:
## or FQDNs/IP addresses if these services are running outside of Docker ## or FQDNs/IP addresses if these services are running outside of Docker
POSTGRES_HOST: sql POSTGRES_HOST: sql
## Credentials for database: ## Credentials for database:
POSTGRES_USER: POSTGRES_USER: szuru
POSTGRES_PASSWORD: POSTGRES_PASSWORD: changeme
## Commented Values are Default: ## Commented Values are Default:
#POSTGRES_DB: defaults to same as POSTGRES_USER #POSTGRES_DB: defaults to same as POSTGRES_USER
#POSTGRES_PORT: 5432 #POSTGRES_PORT: 5432
#LOG_SQL: 0 (1 for verbose SQL logs) #LOG_SQL: 0 (1 for verbose SQL logs)
THREADS: THREADS:
UID: ${UID}
GID: ${GID}
user: "${UID}:${GID}"
volumes: volumes:
- "./server:/opt/app"
- "${MOUNT_DATA}:/data" - "${MOUNT_DATA}:/data"
- "./server/config.yaml:/opt/app/config.yaml" - "./server/config.yaml:/opt/app/config.yaml"
client: client:
image: szurubooru/client:latest # image: szurubooru/client:latest
build:
context: ./client
depends_on: depends_on:
- server - server
environment: environment:
@ -44,5 +48,7 @@ services:
environment: environment:
POSTGRES_USER: POSTGRES_USER:
POSTGRES_PASSWORD: POSTGRES_PASSWORD:
ports:
- 15432:5432
volumes: volumes:
- "${MOUNT_SQL}:/var/lib/postgresql/data" - "${MOUNT_SQL}:/var/lib/postgresql/data"

3
rebuild-and-run.sh Executable file
View file

@ -0,0 +1,3 @@
docker compose down --volumes
docker compose build client
docker compose up -d

View file

@ -65,6 +65,14 @@ def create_post(
ctx.user, "uploads:use_downloader" ctx.user, "uploads:use_downloader"
), ),
) )
title = ""
if ctx.has_param("title"):
title = ctx.get_param_as_string("title")
description = ""
if ctx.has_param("description"):
description = ctx.get_param_as_string("description")
tag_names = ctx.get_param_as_string_list("tags", default=[]) tag_names = ctx.get_param_as_string_list("tags", default=[])
safety = ctx.get_param_as_string("safety") safety = ctx.get_param_as_string("safety")
source = ctx.get_param_as_string("source", default="") source = ctx.get_param_as_string("source", default="")
@ -81,6 +89,8 @@ def create_post(
) )
if len(new_tags): if len(new_tags):
auth.verify_privilege(ctx.user, "tags:create") auth.verify_privilege(ctx.user, "tags:create")
posts.update_post_title(post, title)
posts.update_post_description(post, description)
posts.update_post_safety(post, safety) posts.update_post_safety(post, safety)
posts.update_post_source(post, source) posts.update_post_source(post, source)
posts.update_post_relations(post, relations) posts.update_post_relations(post, relations)
@ -143,6 +153,14 @@ def update_post(ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
db.session.flush() db.session.flush()
for tag in new_tags: for tag in new_tags:
snapshots.create(tag, ctx.user) snapshots.create(tag, ctx.user)
if ctx.has_param("title"):
# for now we're just treating this fields as "content"
auth.verify_privilege(ctx.user, "posts:edit:content")
posts.update_post_title(post, ctx.get_param_as_string("title"))
if ctx.has_param("description"):
# for now we're just treating this fields as "content"
auth.verify_privilege(ctx.user, "posts:edit:content")
posts.update_post_description(post, ctx.get_param_as_string("description"))
if ctx.has_param("safety"): if ctx.has_param("safety"):
auth.verify_privilege(ctx.user, "posts:edit:safety") auth.verify_privilege(ctx.user, "posts:edit:safety")
posts.update_post_safety(post, ctx.get_param_as_string("safety")) posts.update_post_safety(post, ctx.get_param_as_string("safety"))

View file

@ -169,6 +169,8 @@ class PostSerializer(serialization.BaseSerializer):
"version": self.serialize_version, "version": self.serialize_version,
"creationTime": self.serialize_creation_time, "creationTime": self.serialize_creation_time,
"lastEditTime": self.serialize_last_edit_time, "lastEditTime": self.serialize_last_edit_time,
"title": self.serialize_title,
"description": self.serialize_description,
"safety": self.serialize_safety, "safety": self.serialize_safety,
"source": self.serialize_source, "source": self.serialize_source,
"type": self.serialize_type, "type": self.serialize_type,
@ -213,6 +215,12 @@ class PostSerializer(serialization.BaseSerializer):
def serialize_last_edit_time(self) -> Any: def serialize_last_edit_time(self) -> Any:
return self.post.last_edit_time return self.post.last_edit_time
def serialize_title(self) -> Any:
return self.post.title
def serialize_description(self) -> Any:
return self.post.description
def serialize_safety(self) -> Any: def serialize_safety(self) -> Any:
return SAFETY_MAP[self.post.safety] return SAFETY_MAP[self.post.safety]
@ -414,6 +422,8 @@ def create_post(
post.creation_time = datetime.utcnow() post.creation_time = datetime.utcnow()
post.flags = [] post.flags = []
post.title = ""
post.description = ""
post.type = "" post.type = ""
post.checksum = "" post.checksum = ""
post.mime_type = "" post.mime_type = ""
@ -441,6 +451,18 @@ def update_post_source(post: model.Post, source: Optional[str]) -> None:
raise InvalidPostSourceError("Source is too long.") raise InvalidPostSourceError("Source is too long.")
post.source = source or None post.source = source or None
def update_post_title(post: model.Post, title: str) -> None:
assert post
if util.value_exceeds_column_size(title, model.Post.title):
raise InvalidPostSourceError("Title is too long.")
post.title = title
def update_post_description(post: model.Post, description: str) -> None:
assert post
if util.value_exceeds_column_size(description, model.Post.description):
raise InvalidPostSourceError("Description is too long.")
post.description = description
@sa.events.event.listens_for(model.Post, "after_insert") @sa.events.event.listens_for(model.Post, "after_insert")
def _after_post_insert( def _after_post_insert(

View file

@ -0,0 +1,24 @@
'''
Add title and description to posts
Revision ID: 0c149800a728
Created at: 2024-12-03 08:21:21.113161
'''
import sqlalchemy as sa
from alembic import op
revision = '0c149800a728'
down_revision = 'adcd63ff76a2'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('post', sa.Column('title', sa.Unicode(length=512), nullable=False))
op.add_column('post', sa.Column('description', sa.Unicode(length=2048), nullable=False))
def downgrade():
op.drop_column('post', 'description')
op.drop_column('post', 'title')

View file

@ -215,6 +215,8 @@ class Post(Base):
flags_string = sa.Column("flags", sa.Unicode(32), default="") flags_string = sa.Column("flags", sa.Unicode(32), default="")
# content description # content description
title = sa.Column("title", sa.Unicode(512), nullable=False, default="")
description = sa.Column("description", sa.Unicode(2048), nullable=False, default="")
type = sa.Column("type", sa.Unicode(32), nullable=False) type = sa.Column("type", sa.Unicode(32), nullable=False)
checksum = sa.Column("checksum", sa.Unicode(64), nullable=False) checksum = sa.Column("checksum", sa.Unicode(64), nullable=False)
checksum_md5 = sa.Column("checksum_md5", sa.Unicode(32)) checksum_md5 = sa.Column("checksum_md5", sa.Unicode(32))