Compare commits

..

No commits in common. "1987fae3046edf5630711ebf9edb05cd1f89925c" and "3c6d52ed280720c320701f2f355ac140e2e6ca4d" have entirely different histories.

23 changed files with 334 additions and 664 deletions

View file

@ -1,12 +1,5 @@
# Whats New # Whats New
## 1.1.0
+ **Deprecated** several configuration values ~
+ **Schema changes**: several columns changed format. Update your schema.
+ Removed permanently the remains of extensions.
+ I18n improvements.
## 1.0.0 ## 1.0.0
+ **BREAKING CHANGES AHEAD**! + **BREAKING CHANGES AHEAD**!
@ -18,7 +11,7 @@
+ Switched to `pyproject.toml`. `requirements.txt` has been sunset. + Switched to `pyproject.toml`. `requirements.txt` has been sunset.
+ Switched to the Apache License; the old license text is moved to `LICENSE.0_9` + Switched to the Apache License; the old license text is moved to `LICENSE.0_9`
+ Added color themes! This is a breaking (but trivial) aesthetic change. Default theme is 'Miku' (aquamarine green). + Added color themes! This is a breaking (but trivial) aesthetic change. Default theme is 'Miku' (aquamarine green).
+ Extensions **have been removed**. They never had a clear, usable, public API in the first place. + Extensions have been removed. They never had a clear, usable, public API in the first place.
## 0.9 ## 0.9

View file

@ -1 +0,0 @@
Generic single-database configuration.

View file

@ -1,79 +0,0 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from salvi.models import Base
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -1,26 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -1,86 +0,0 @@
"""empty message
Sorry, due to unattended changes, upgrade from 1.0.0 is not reliable and untested. Sorry.
Revision ID: ae0587e14725
Revises:
Create Date: 2025-10-04 09:43:41.158057
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = 'ae0587e14725'
down_revision: Union[str, None] = 'ebde30d24167'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
#op.drop_index('pagepolicykey_passphrase_sec_code', table_name='pagepolicykey')
#op.drop_index('page_owner', table_name='page')
op.create_index(op.f('ix_page_calendar'), 'page', ['calendar'], unique=False)
op.create_index('page_calendar', 'page', ['calendar'], unique=False)
op.drop_index('user_id', table_name='usergroupmembership')
op.drop_index('user_id_2', table_name='usergroupmembership')
#op.drop_index('usergroupmembership_group_id', table_name='usergroupmembership')
#op.drop_index('usergroupmembership_user_id', table_name='usergroupmembership')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_index('usergroupmembership_user_id', 'usergroupmembership', ['user_id'], unique=False)
op.create_index('usergroupmembership_group_id', 'usergroupmembership', ['group_id'], unique=False)
op.create_index('user_id_2', 'usergroupmembership', ['user_id', 'group_id'], unique=True)
op.create_index('user_id', 'usergroupmembership', ['user_id', 'group_id'], unique=True)
op.drop_index('page_calendar', table_name='page')
op.drop_index(op.f('ix_page_calendar'), table_name='page')
op.create_index('page_owner', 'page', ['owner_id'], unique=False)
op.create_table('upload',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('name', mysql.VARCHAR(length=256), nullable=False),
sa.Column('url_name', mysql.VARCHAR(length=256), nullable=True),
sa.Column('filetype', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('filesize', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('upload_date', mysql.DATETIME(), nullable=False),
sa.Column('md5', mysql.VARCHAR(length=32), nullable=False),
sa.PrimaryKeyConstraint('id'),
mariadb_collate='utf8mb4_general_ci',
mariadb_default_charset='utf8mb4',
mariadb_engine='InnoDB'
)
op.create_index('upload_upload_date', 'upload', ['upload_date'], unique=False)
op.create_index('upload_md5', 'upload', ['md5'], unique=False)
op.create_table('pagepolicy',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('page_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
sa.Column('type', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('key_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('sitewide', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['key_id'], ['pagepolicykey.id'], name='pagepolicy_FK_0_0'),
sa.ForeignKeyConstraint(['page_id'], ['page.id'], name='pagepolicy_FK_1_0'),
sa.PrimaryKeyConstraint('id'),
mariadb_collate='utf8mb4_general_ci',
mariadb_default_charset='utf8mb4',
mariadb_engine='InnoDB'
)
op.create_index('pagepolicy_page_id_key_id', 'pagepolicy', ['page_id', 'key_id'], unique=True)
op.create_index('pagepolicy_page_id', 'pagepolicy', ['page_id'], unique=False)
op.create_index('pagepolicy_key_id', 'pagepolicy', ['key_id'], unique=False)
op.create_table('pagepolicykey',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('passphrase', mysql.VARCHAR(length=255), nullable=False),
sa.Column('sec_code', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id'),
mariadb_collate='utf8mb4_general_ci',
mariadb_default_charset='utf8mb4',
mariadb_engine='InnoDB'
)
op.create_index('pagepolicykey_passphrase_sec_code', 'pagepolicykey', ['passphrase', 'sec_code'], unique=True)
# ### end Alembic commands ###

View file

@ -1,312 +0,0 @@
"""empty message
Revision ID: ebde30d24167
Revises: ae0587e14725
Create Date: 2025-10-10 19:01:04.309127
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = 'ebde30d24167'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('pagepolicy',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('page_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
sa.Column('type', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('key_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('sitewide', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['key_id'], ['pagepolicykey.id'], name='pagepolicy_FK_0_0'),
sa.ForeignKeyConstraint(['page_id'], ['page.id'], name='pagepolicy_FK_1_0'),
sa.PrimaryKeyConstraint('id'),
mariadb_collate='utf8mb4_general_ci',
mariadb_default_charset='utf8mb4',
mariadb_engine='InnoDB'
)
op.create_index('pagepolicy_page_id_key_id', 'pagepolicy', ['page_id', 'key_id'], unique=True)
op.create_index('pagepolicy_page_id', 'pagepolicy', ['page_id'], unique=False)
op.create_index('pagepolicy_key_id', 'pagepolicy', ['key_id'], unique=False)
op.create_table('pagepolicykey',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('passphrase', mysql.VARCHAR(length=255), nullable=False),
sa.Column('sec_code', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id'),
mariadb_collate='utf8mb4_general_ci',
mariadb_default_charset='utf8mb4',
mariadb_engine='InnoDB'
)
op.alter_column('page', 'title',
existing_type=mysql.VARCHAR(length=256),
nullable=True)
op.alter_column('page', 'touched',
existing_type=mysql.DATETIME(),
nullable=True)
op.alter_column('page', 'flags',
existing_type=mysql.BIGINT(display_width=20),
nullable=True)
op.drop_index('page_calendar', table_name='page')
op.drop_index('page_owner', table_name='page')
op.drop_index('page_title', table_name='page')
op.drop_index('page_touched', table_name='page')
op.create_index(op.f('ix_page_title'), 'page', ['title'], unique=False)
op.create_index(op.f('ix_page_touched'), 'page', ['touched'], unique=False)
op.alter_column('pagelink', 'from_page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pagelink', 'to_page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.drop_constraint('pagelink_ibfk_2', 'pagelink', type_='foreignkey')
op.drop_constraint('pagelink_ibfk_1', 'pagelink', type_='foreignkey')
op.create_foreign_key(None, 'pagelink', 'page', ['from_page_id'], ['id'])
op.create_foreign_key(None, 'pagelink', 'page', ['to_page_id'], ['id'])
op.alter_column('pagepermission', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pagepermission', 'group_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pagepermission', 'permissions',
existing_type=mysql.BIGINT(display_width=20),
nullable=True)
op.drop_constraint('pagepermission_ibfk_4', 'pagepermission', type_='foreignkey')
op.drop_constraint('pagepermission_ibfk_5', 'pagepermission', type_='foreignkey')
op.create_foreign_key(None, 'pagepermission', 'usergroup', ['group_id'], ['id'])
op.create_foreign_key(None, 'pagepermission', 'page', ['page_id'], ['id'])
op.alter_column('pageproperty', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pageproperty', 'key',
existing_type=mysql.VARCHAR(length=64),
nullable=True)
op.alter_column('pageproperty', 'value',
existing_type=mysql.VARCHAR(length=8000),
nullable=True)
op.alter_column('pagerevision', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pagerevision', 'comment',
existing_type=mysql.VARCHAR(length=1024),
nullable=True)
op.alter_column('pagerevision', 'textref_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pagerevision', 'pub_date',
existing_type=mysql.DATETIME(),
nullable=True)
op.alter_column('pagerevision', 'length',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.drop_constraint('pagerevision_ibfk_1', 'pagerevision', type_='foreignkey')
op.drop_constraint('pagerevision_ibfk_2', 'pagerevision', type_='foreignkey')
op.drop_constraint('pagerevision_ibfk_3', 'pagerevision', type_='foreignkey')
op.create_foreign_key(None, 'pagerevision', 'pagetext', ['textref_id'], ['id'])
op.create_foreign_key(None, 'pagerevision', 'page', ['page_id'], ['id'])
op.create_foreign_key(None, 'pagerevision', 'user', ['user_id'], ['id'])
op.alter_column('pagetag', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pagetag', 'name',
existing_type=mysql.VARCHAR(length=64),
nullable=True)
op.drop_constraint('pagetag_ibfk_1', 'pagetag', type_='foreignkey')
op.create_foreign_key(None, 'pagetag', 'page', ['page_id'], ['id'])
op.alter_column('pagetext', 'content',
existing_type=sa.BLOB(),
nullable=True)
op.alter_column('pagetext', 'flags',
existing_type=mysql.BIGINT(display_width=20),
nullable=True)
op.add_column('user', sa.Column('privileges', sa.BigInteger(), nullable=True))
op.add_column('user', sa.Column('restrictions', sa.BigInteger(), nullable=True))
op.alter_column('user', 'username',
existing_type=mysql.VARCHAR(length=32),
nullable=True)
op.alter_column('user', 'password',
existing_type=mysql.VARCHAR(length=255),
nullable=True)
op.alter_column('user', 'join_date',
existing_type=mysql.DATETIME(),
nullable=True)
op.alter_column('user', 'karma',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.drop_column('user', 'is_disabled')
op.drop_column('user', 'is_admin')
op.drop_column('user', 'color_theme')
op.alter_column('usergroup', 'name',
existing_type=mysql.VARCHAR(length=32),
nullable=True)
op.alter_column('usergroup', 'permissions',
existing_type=mysql.BIGINT(display_width=20),
nullable=True)
op.alter_column('usergroupmembership', 'user_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('usergroupmembership', 'group_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('usergroupmembership', 'since',
existing_type=mysql.DATETIME(),
nullable=True)
op.drop_index('usergroupmembership_group_id', table_name='usergroupmembership')
op.drop_index('usergroupmembership_user_id', table_name='usergroupmembership')
# ### end Alembic commands ###
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_index('usergroupmembership_user_id', 'usergroupmembership', ['user_id'], unique=False)
op.create_index('usergroupmembership_group_id', 'usergroupmembership', ['group_id'], unique=False)
op.alter_column('usergroupmembership', 'since',
existing_type=mysql.DATETIME(),
nullable=False)
op.alter_column('usergroupmembership', 'group_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('usergroupmembership', 'user_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('usergroup', 'permissions',
existing_type=mysql.BIGINT(display_width=20),
nullable=False)
op.alter_column('usergroup', 'name',
existing_type=mysql.VARCHAR(length=32),
nullable=False)
op.add_column('user', sa.Column('color_theme', mysql.SMALLINT(display_width=6), server_default=sa.text('0'), autoincrement=False, nullable=False))
op.add_column('user', sa.Column('is_admin', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=False))
op.add_column('user', sa.Column('is_disabled', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=False))
op.alter_column('user', 'karma',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('user', 'join_date',
existing_type=mysql.DATETIME(),
nullable=False)
op.alter_column('user', 'password',
existing_type=mysql.VARCHAR(length=255),
nullable=False)
op.alter_column('user', 'username',
existing_type=mysql.VARCHAR(length=32),
nullable=False)
op.drop_column('user', 'restrictions')
op.drop_column('user', 'privileges')
op.alter_column('pagetext', 'flags',
existing_type=mysql.BIGINT(display_width=20),
nullable=False)
op.alter_column('pagetext', 'content',
existing_type=sa.BLOB(),
nullable=False)
op.drop_constraint(None, 'pagetag', type_='foreignkey')
op.create_foreign_key('pagetag_ibfk_1', 'pagetag', 'page', ['page_id'], ['id'], ondelete='CASCADE')
op.alter_column('pagetag', 'name',
existing_type=mysql.VARCHAR(length=64),
nullable=False)
op.alter_column('pagetag', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.drop_constraint(None, 'pagerevision', type_='foreignkey')
op.drop_constraint(None, 'pagerevision', type_='foreignkey')
op.drop_constraint(None, 'pagerevision', type_='foreignkey')
op.create_foreign_key('pagerevision_ibfk_3', 'pagerevision', 'user', ['user_id'], ['id'], ondelete='SET NULL')
op.create_foreign_key('pagerevision_ibfk_2', 'pagerevision', 'page', ['page_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('pagerevision_ibfk_1', 'pagerevision', 'pagetext', ['textref_id'], ['id'], ondelete='CASCADE')
op.alter_column('pagerevision', 'length',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('pagerevision', 'pub_date',
existing_type=mysql.DATETIME(),
nullable=False)
op.alter_column('pagerevision', 'textref_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('pagerevision', 'comment',
existing_type=mysql.VARCHAR(length=1024),
nullable=False)
op.alter_column('pagerevision', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('pageproperty', 'value',
existing_type=mysql.VARCHAR(length=8000),
nullable=False)
op.alter_column('pageproperty', 'key',
existing_type=mysql.VARCHAR(length=64),
nullable=False)
op.alter_column('pageproperty', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.drop_constraint(None, 'pagepermission', type_='foreignkey')
op.drop_constraint(None, 'pagepermission', type_='foreignkey')
op.create_foreign_key('pagepermission_ibfk_5', 'pagepermission', 'usergroup', ['group_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('pagepermission_ibfk_4', 'pagepermission', 'page', ['page_id'], ['id'], ondelete='CASCADE')
op.alter_column('pagepermission', 'permissions',
existing_type=mysql.BIGINT(display_width=20),
nullable=False)
op.alter_column('pagepermission', 'group_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('pagepermission', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.drop_constraint(None, 'pagelink', type_='foreignkey')
op.drop_constraint(None, 'pagelink', type_='foreignkey')
op.create_foreign_key('pagelink_ibfk_1', 'pagelink', 'page', ['to_page_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('pagelink_ibfk_2', 'pagelink', 'page', ['from_page_id'], ['id'], ondelete='CASCADE')
op.alter_column('pagelink', 'to_page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('pagelink', 'from_page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.drop_index(op.f('ix_page_touched'), table_name='page')
op.drop_index(op.f('ix_page_title'), table_name='page')
op.create_index('page_touched', 'page', ['touched'], unique=False)
op.create_index('page_title', 'page', ['title'], unique=False)
op.create_index('page_owner', 'page', ['owner_id'], unique=False)
op.create_index('page_calendar', 'page', ['calendar'], unique=False)
op.alter_column('page', 'flags',
existing_type=mysql.BIGINT(display_width=20),
nullable=False)
op.alter_column('page', 'touched',
existing_type=mysql.DATETIME(),
nullable=False)
op.alter_column('page', 'title',
existing_type=mysql.VARCHAR(length=256),
nullable=False)
op.create_table('upload',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('name', mysql.VARCHAR(length=256), nullable=False),
sa.Column('url_name', mysql.VARCHAR(length=256), nullable=True),
sa.Column('filetype', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('filesize', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('upload_date', mysql.DATETIME(), nullable=False),
sa.Column('md5', mysql.VARCHAR(length=32), nullable=False),
sa.PrimaryKeyConstraint('id'),
mariadb_collate='utf8mb4_general_ci',
mariadb_default_charset='utf8mb4',
mariadb_engine='InnoDB'
)
op.create_index('upload_upload_date', 'upload', ['upload_date'], unique=False)
op.create_index('upload_md5', 'upload', ['md5'], unique=False)
try:
## drop tables removed in 0.x
op.drop_table('pagepolicykey')
op.drop_index('pagepolicy_key_id', table_name='pagepolicy')
op.drop_index('pagepolicy_page_id', table_name='pagepolicy')
op.drop_index('pagepolicy_page_id_key_id', table_name='pagepolicy')
op.drop_table('pagepolicy')
op.drop_index('upload_md5', table_name='upload')
op.drop_index('upload_upload_date', table_name='upload')
op.drop_table('upload')
except Exception:
pass
# ### end Alembic commands ###

View file

@ -9,7 +9,6 @@
"back-to": "Back to", "back-to": "Back to",
"backlinks": "Backlinks", "backlinks": "Backlinks",
"backlinks-empty": "No other pages linking here. Is this page orphan?", "backlinks-empty": "No other pages linking here. Is this page orphan?",
"bad-request": "Bad request",
"calculate": "Calculate", "calculate": "Calculate",
"calendar": "Calendar", "calendar": "Calendar",
"confirm-password": "Confirm password", "confirm-password": "Confirm password",
@ -19,8 +18,6 @@
"groups-count": "User group count", "groups-count": "User group count",
"have-read-terms": "I have read {0} and {1}", "have-read-terms": "I have read {0} and {1}",
"homepage": "Homepage", "homepage": "Homepage",
"in-the-future": "in the future",
"in-the-past": "in the past",
"include-tags": "Include tags", "include-tags": "Include tags",
"input-tags": "Tags (comma separated)", "input-tags": "Tags (comma separated)",
"jump-to-actions": "Jump to actions", "jump-to-actions": "Jump to actions",
@ -30,7 +27,6 @@
"latest-uploads": "Latest uploads", "latest-uploads": "Latest uploads",
"logged-in-as": "Logged in as", "logged-in-as": "Logged in as",
"login": "Log in", "login": "Log in",
"manage-accounts": "Manage accounts",
"month": "Month", "month": "Month",
"n-days-ago": "{0} days ago", "n-days-ago": "{0} days ago",
"n-hours-ago": "{0} hours ago", "n-hours-ago": "{0} hours ago",
@ -44,7 +40,6 @@
"not-found-text-2": "does not exist.", "not-found-text-2": "does not exist.",
"not-logged-in": "Not logged in", "not-logged-in": "Not logged in",
"note-history": "Page history", "note-history": "Page history",
"notes-by-date": "Pages by date",
"notes-count": "Number of pages", "notes-count": "Number of pages",
"notes-count-with-url": "Number of pages with URL set", "notes-count-with-url": "Number of pages with URL set",
"notes-month-empty": "None found :(", "notes-month-empty": "None found :(",
@ -67,13 +62,10 @@
"search-results": "Search results for", "search-results": "Search results for",
"search-no-results": "No results for", "search-no-results": "No results for",
"show-all": "Show all", "show-all": "Show all",
"show-more-years": "Show more years",
"sign-up": "Sign up", "sign-up": "Sign up",
"tags": "Tags", "tags": "Tags",
"terms-of-service": "Terms of Service", "terms-of-service": "Terms of Service",
"upload-file": "Upload file", "upload-file": "Upload file",
"user-generated-warning": "Any content in this site is user-generated and",
"user-liability-warning": "each user is responsible for what they publish",
"username": "Username", "username": "Username",
"users-count": "Number of users", "users-count": "Number of users",
"welcome": "Welcome to {0}!", "welcome": "Welcome to {0}!",

View file

@ -9,7 +9,6 @@
"back-to": "Torna a", "back-to": "Torna a",
"backlinks": "Collegamenti in entrata", "backlinks": "Collegamenti in entrata",
"backlinks-empty": "Nessuna altra pagina punta qui. Questa pagina è orfana?", "backlinks-empty": "Nessuna altra pagina punta qui. Questa pagina è orfana?",
"bad-request": "Richiesta non conforme",
"calculate": "Calcola", "calculate": "Calcola",
"calendar": "Calendario", "calendar": "Calendario",
"confirm-password": "Conferma password", "confirm-password": "Conferma password",

View file

@ -15,7 +15,7 @@ dependencies = [
"Flask-WTF", "Flask-WTF",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"pymysql", "pymysql",
"sakuragasaki46-suou>=0.7.0" "sakuragasaki46-suou>=0.6.0"
] ]
requires-python = ">=3.10" requires-python = ">=3.10"
classifiers = [ classifiers = [

View file

@ -8,21 +8,27 @@ Pages are stored in SQLite/MySQL databases.
Markdown is used for text formatting. Markdown is used for text formatting.
''' '''
__version__ = '1.1.0-dev40' __version__ = '1.0.0'
from flask import ( from flask import (
Flask, g, make_response, redirect, Flask, abort, flash, g, jsonify, make_response, redirect,
request, render_template, send_from_directory request, render_template, send_from_directory
) )
from markupsafe import Markup from markupsafe import Markup
from flask_login import LoginManager from flask_login import LoginManager, login_user, logout_user, current_user, login_required
from flask_wtf import CSRFProtect from flask_wtf import CSRFProtect
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import select from sqlalchemy import select
from suou.configparse import ConfigOptions, ConfigParserConfigSource, ConfigValue from suou.configparse import ConfigOptions, ConfigParserConfigSource, ConfigValue
from suou.flask import add_context_from_config from suou.flask import add_context_from_config
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
import html, os import datetime, hashlib, html, importlib, json, markdown, os, random, \
from functools import partial re, sys, warnings
from functools import lru_cache, partial
from urllib.parse import quote
import gzip
from getpass import getpass
from configparser import ConfigParser from configparser import ConfigParser
import dotenv import dotenv
@ -34,13 +40,11 @@ dotenv.load_dotenv(os.path.join(APP_BASE_DIR, '.env'))
class SiteConfig(ConfigOptions): class SiteConfig(ConfigOptions):
app_name = ConfigValue(default='Salvi', legacy_src='site.title', public=True) app_name = ConfigValue(default='Salvi', legacy_src='site.title', public=True)
## v--- will change to server_name in 2.0.0
domain_name = ConfigValue(public=True) domain_name = ConfigValue(public=True)
database_url = ConfigValue(legacy_src='database.url', required=True) database_url = ConfigValue(legacy_src='database.url', required=True)
secret_key = ConfigValue(required=True) secret_key = ConfigValue(required=True)
default_group = ConfigValue(prefix='salvi_', cast=int, default=1) default_group = ConfigValue(prefix='salvi_', cast=int, default=1)
material_icons_url = ConfigValue(default='https://fonts.googleapis.com/icon?family=Material+Icons', public=True) material_icons_url = ConfigValue(default='https://fonts.googleapis.com/icon?family=Material+Icons', public=True)
# v--- pending deprecation
default_items_per_page = ConfigValue(prefix='salvi_', legacy_src='appearance.items_per_page', default=20, cast=int) default_items_per_page = ConfigValue(prefix='salvi_', legacy_src='appearance.items_per_page', default=20, cast=int)
... ...

View file

@ -9,7 +9,7 @@ PING_RE = r'(?<!\w)@(' + USERNAME_RE + r')'
FORBIDDEN_URLS = [ FORBIDDEN_URLS = [
'about', 'accounts', 'ajax', 'backlinks', 'calendar', 'circles', 'create', 'about', 'accounts', 'ajax', 'backlinks', 'calendar', 'circles', 'create',
'easter', 'edit', 'embed', 'group', 'help', 'history', 'init-config', 'easter', 'edit', 'embed', 'group', 'help', 'history', 'init-config', 'kt',
'manage', 'media', 'p', 'privacy', 'protect', 'rules', 'search', 'static', 'manage', 'media', 'p', 'privacy', 'protect', 'rules', 'search', 'static',
'stats', 'tags', 'terms', 'theme-switch', 'timeline', 'u', 'upload', 'upload-info', 'v1' 'stats', 'tags', 'terms', 'theme-switch', 'u', 'upload', 'upload-info', 'v1'
] ]

View file

@ -6,20 +6,22 @@ from __future__ import annotations
from functools import partial, wraps from functools import partial, wraps
from getpass import getpass from getpass import getpass
import os
import re import re
import sys import sys
from typing import Any, Callable, Iterable, List, Mapping from typing import Any, Callable, Iterable, List, Mapping
import datetime import warnings
import gzip
# sqlalchemy imports
from flask import abort, flash from flask import abort, flash
from flask_login import AnonymousUserMixin, current_user, login_required from flask_login import AnonymousUserMixin, current_user, login_required
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from markupsafe import Markup from markupsafe import Markup
from sqlalchemy.orm import Relationship, declarative_base, deferred, relationship
# sqlalchemy imports from sqlalchemy import Column, Integer, String, DateTime, BigInteger, ForeignKey, UniqueConstraint, create_engine, Index, BLOB as Blob, delete, func, insert, or_, select, update
from sqlalchemy.orm import Mapped, Relationship, declarative_base, mapped_column, relationship import datetime
from sqlalchemy import Index, Integer, SmallInteger, String, DateTime, BigInteger, ForeignKey, UniqueConstraint, BLOB as Blob, delete, func, insert, or_, select, text, update import os
import gzip
from suou.functools import deprecated, deprecated_alias, not_implemented from suou.functools import deprecated, deprecated_alias, not_implemented
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
@ -31,8 +33,59 @@ from .i18n import human_elapsed_time
current_user: User current_user: User
## TODO consider moving it to suou.
## TODO consider sqlalchemy.ext.hybrid.hybrid_property works
from sqlalchemy.ext.hybrid import Comparator
class _BitComparator(Comparator):
_column: Column
_flag: int
def __init__(self, col, flag):
self._column = col
self._flag = flag
def _bulk_update_tuples(self, value):
return [ (self._column, self._upd_exp(value)) ]
def operate(self, op, other, **kwargs):
return op(self._sel_exp(), self._flag if other else 0, **kwargs)
def __clause_element__(self):
return self._column
def __str__(self):
return self._column
def _sel_exp(self):
return self._column.op('&')(self._flag)
def _upd_exp(self, value):
return self._column.op('|')(self._flag) if value else self._column.op('&')(~self._flag)
class BitSelector:
_column: Column
_flag: int
_name: str
def __init__(self, column, flag: int):
if bin(flag := int(flag))[2:].rstrip('0') != '1':
warnings.warn('using non-powers of 2 as flags may cause errors or undefined behavior', FutureWarning)
self._column = column
self._flag = flag
def __set_name__(self, name, owner=None):
self._name = name
def __get__(self, obj, objtype=None):
if obj:
return getattr(obj, self._column.name) & self._flag > 0
else:
return _BitComparator(self._column, self._flag)
def __set__(self, obj, val):
if obj:
orig = getattr(obj, self._column.name)
if val:
orig |= self._flag
else:
orig &= ~(self._flag)
setattr(obj, self._column.name, orig)
else:
raise NotImplementedError
# Helper for interactive session management # Helper for interactive session management
from suou.sqlalchemy import bool_column, bound_fk, create_session, declarative_base, BitSelector, unbound_fk from suou.sqlalchemy import create_session, declarative_base
CSI = create_session_interactively = partial(create_session, app_config.database_url) CSI = create_session_interactively = partial(create_session, app_config.database_url)
@ -43,23 +96,21 @@ db = SQLAlchemy(model_class=Base)
class User(db.Model): class User(db.Model):
__tablename__ = 'user' __tablename__ = 'user'
id: Mapped[int] = mapped_column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(32), unique=True) username = Column(String(32), unique=True)
email: Mapped[str | None] = mapped_column(String(256), nullable=True) email = Column(String(256), nullable=True)
password: Mapped[str] = mapped_column(String(255)) password = Column(String(255))
join_date: Mapped[int] = mapped_column(DateTime, server_default=func.current_timestamp()) # will change to server_default in 1.1
karma: Mapped[int] = mapped_column(Integer, server_default=text('1')) join_date = Column(DateTime, default=datetime.datetime.now)
is_admin: Mapped[bool] = bool_column() karma = Column(Integer, default=1)
is_disabled: Mapped[int] = bool_column() privileges = Column(BigInteger, default=0)
color_theme: Mapped[int] = mapped_column(SmallInteger, server_default=text('0')) is_admin = BitSelector(privileges, 1)
restrictions = Column(BigInteger, default=0)
is_disabled = BitSelector(restrictions, 1)
owned_pages: Relationship[List[Page]] = relationship('Page', back_populates='owner') owned_pages: Relationship[List[Page]] = relationship('Page', back_populates='owner')
group_memberships: Relationship[UserGroupMembership] = relationship('UserGroupMembership', back_populates='user') group_memberships: Relationship[UserGroupMembership] = relationship('UserGroupMembership', back_populates='user')
contributions: Relationship[PageRevision] = relationship("PageRevision", back_populates='user') contributions = relationship("PageRevision", back_populates='user')
__table_args__ = (
UniqueConstraint(username, name='user_username'),
)
# helpers for flask_login # helpers for flask_login
@property @property
@ -119,18 +170,14 @@ PERM_LOCK = PERM_READ
class UserGroup(db.Model): class UserGroup(db.Model):
__tablename__ = 'usergroup' __tablename__ = 'usergroup'
id: Mapped[int] = mapped_column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(32), unique=True) name = Column(String(32), unique=True)
permissions: Mapped[int] = mapped_column(BigInteger, server_default=text('0')) permissions = Column(BigInteger, default=0)
can_read: Mapped[bool] = BitSelector(permissions, PERM_READ) can_read = BitSelector(permissions, PERM_READ)
can_edit: Mapped[bool] = BitSelector(permissions, PERM_EDIT) can_edit = BitSelector(permissions, PERM_EDIT)
can_create: Mapped[bool] = BitSelector(permissions, PERM_CREATE) can_create = BitSelector(permissions, PERM_CREATE)
can_set_url: Mapped[bool] = BitSelector(permissions, PERM_SET_URL) can_set_url = BitSelector(permissions, PERM_SET_URL)
can_set_tags: Mapped[bool] = BitSelector(permissions, PERM_SET_TAGS) can_set_tags = BitSelector(permissions, PERM_SET_TAGS)
__table_args__ = (
UniqueConstraint(name, name='usergroup_name'),
)
user_memberships: Relationship[UserGroupMembership] = relationship('UserGroupMembership', back_populates='group') user_memberships: Relationship[UserGroupMembership] = relationship('UserGroupMembership', back_populates='group')
page_permissions: Relationship[PagePermission] = relationship('PagePermission', back_populates = 'group') page_permissions: Relationship[PagePermission] = relationship('PagePermission', back_populates = 'group')
@ -151,16 +198,15 @@ class UserGroup(db.Model):
class UserGroupMembership(db.Model): class UserGroupMembership(db.Model):
__tablename__ = 'usergroupmembership' __tablename__ = 'usergroupmembership'
id: Mapped[int] = mapped_column(Integer, primary_key = True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
group_id: Mapped[int] = mapped_column(Integer, ForeignKey('usergroup.id'))
since: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.now)
__table_args__ = ( __table_args__ = (
UniqueConstraint(user_id, group_id, name="usergroupmembership_user_id_group_id"), UniqueConstraint('user_id', 'group_id'),
) )
id = Column(Integer, primary_key = True)
user_id = Column(Integer, ForeignKey('user.id'))
group_id = Column(Integer, ForeignKey('usergroup.id'))
since = Column(DateTime, default=datetime.datetime.now)
user: Relationship[User] = relationship("User", back_populates='group_memberships') user: Relationship[User] = relationship("User", back_populates='group_memberships')
group: Relationship[UserGroup] = relationship("UserGroup", back_populates='user_memberships') group: Relationship[UserGroup] = relationship("UserGroup", back_populates='user_memberships')
@ -168,26 +214,18 @@ class UserGroupMembership(db.Model):
class Page(db.Model): class Page(db.Model):
__tablename__ = 'page' __tablename__ = 'page'
id: Mapped[int] = mapped_column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
# v--- url will be moved to a separate table in 1.2 (?) url = Column(String(64), unique=True, nullable=True)
url: Mapped[str | None] = mapped_column(String(64), unique=True) title = Column(String(256), index=True)
title: Mapped[str] = mapped_column(String(256)) touched = Column(DateTime, index=True)
touched: Mapped[datetime.datetime] = mapped_column(DateTime) calendar = Column(DateTime, index=True, nullable=True)
calendar: Mapped[datetime.datetime | None] = mapped_column(DateTime, index=True) owner_id = Column(Integer, ForeignKey('user.id'), nullable=True)
owner_id: Mapped[int | None] = mapped_column(Integer, ForeignKey('user.id')) flags = Column(BigInteger, default=0)
flags: Mapped[int] = mapped_column(BigInteger, server_default=text('0')) is_redirect = BitSelector(flags, 1)
is_redirect: Mapped[bool] = BitSelector(flags, 1) is_sync = BitSelector(flags, 2)
is_sync: Mapped[bool] = BitSelector(flags, 2) is_math_enabled = BitSelector(flags, 4)
is_math_enabled: Mapped[bool] = BitSelector(flags, 4) is_locked = BitSelector(flags, 8)
is_locked: Mapped[bool] = BitSelector(flags, 8) is_cw = BitSelector(flags, 16)
is_cw: Mapped[bool] = BitSelector(flags, 16)
__table_args__ = (
Index('page_title', title),
Index('page_touched', touched),
UniqueConstraint(url, name='page_url'),
Index('page_calendar', calendar)
)
revisions: Relationship[List[PageRevision]] = relationship("PageRevision", back_populates = 'page') revisions: Relationship[List[PageRevision]] = relationship("PageRevision", back_populates = 'page')
owner: Relationship[User] = relationship('User', back_populates = 'owned_pages') owner: Relationship[User] = relationship('User', back_populates = 'owned_pages')
@ -199,7 +237,7 @@ class Page(db.Model):
def latest(self) -> PageRevision: def latest(self) -> PageRevision:
return db.session.execute( return db.session.execute(
select(PageRevision).where(PageRevision.page_id == self.id).order_by(PageRevision.pub_date.desc()).limit(1) db.select(PageRevision).where(PageRevision.page_id == self.id).order_by(PageRevision.pub_date.desc()).limit(1)
).scalar() ).scalar()
def get_url(self): def get_url(self):
@ -209,7 +247,6 @@ class Page(db.Model):
def by_url(cls, url: str): def by_url(cls, url: str):
return db.session.execute(db.select(Page).where(Page.url == url)).scalar() return db.session.execute(db.select(Page).where(Page.url == url)).scalar()
@deprecated('usage of inefficient and deprecated remove_tags()')
def short_desc(self) -> str: def short_desc(self) -> str:
if self.is_cw: if self.is_cw:
return '(Content Warning: we are not allowed to show a description.)' return '(Content Warning: we are not allowed to show a description.)'
@ -261,6 +298,10 @@ class Page(db.Model):
tags = [x.name for x in self.tags] tags = [x.name for x in self.tags]
) )
@not_implemented
def ldjson():
...
@deprecated('meta name="keywords" is nowadays ignored by search engines') @deprecated('meta name="keywords" is nowadays ignored by search engines')
def seo_keywords(self): def seo_keywords(self):
kw = [] kw = []
@ -326,15 +367,19 @@ class Page(db.Model):
class PageText(db.Model): class PageText(db.Model):
__tablename__ = 'pagetext' __tablename__ = 'pagetext'
id: Mapped[int] = mapped_column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
content: Mapped[bytes] = mapped_column(Blob) content = Column(Blob)
flags: Mapped[int] = mapped_column(BigInteger, default=0) flags = Column(BigInteger, default=0)
is_gzipped: Mapped[bool] = BitSelector(flags, 2) is_utf8 = BitSelector(flags, 1)
is_gzipped = BitSelector(flags, 2)
def get_content(self) -> str: def get_content(self) -> str:
c = self.content c = self.content
if self.is_gzipped: if self.is_gzipped:
c = gzip.decompress(c) c = gzip.decompress(c)
return c.decode('utf-8', 'replace') if self.is_utf8:
return c.decode('utf-8')
else:
return c.decode('latin-1')
@classmethod @classmethod
def create_content(cls, text: str, *, treshold=600, search_dup = True) -> PageText: def create_content(cls, text: str, *, treshold=600, search_dup = True) -> PageText:
c: bytes = text.encode('utf-8') c: bytes = text.encode('utf-8')
@ -347,6 +392,7 @@ class PageText(db.Model):
return item return item
return db.session.execute(insert(cls).values( return db.session.execute(insert(cls).values(
content = c, content = c,
is_utf8 = True,
is_gzipped = use_gzip is_gzipped = use_gzip
).returning(cls)).scalar() ).returning(cls)).scalar()
@ -355,21 +401,16 @@ class PageText(db.Model):
class PageRevision(db.Model): class PageRevision(db.Model):
__tablename__ = 'pagerevision' __tablename__ = 'pagerevision'
id: Mapped[int] = mapped_column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
page_id: Mapped[int] = bound_fk(Page.id) page_id = Column(Integer, ForeignKey('page.id'))
user_id: Mapped[int | None] = unbound_fk(User.id) user_id = Column(Integer, ForeignKey('user.id'), nullable=True)
comment: Mapped[str] = mapped_column(String(1024), default='') comment = Column(String(1024), default='')
textref_id: Mapped[int] = bound_fk(PageText.id) textref_id = Column(Integer, ForeignKey('pagetext.id'))
pub_date: Mapped[datetime.datetime] = mapped_column(DateTime, index=True) pub_date = Column(DateTime, index=True)
length: Mapped[int] = mapped_column() length = Column(Integer)
__table_args__ = ( page = relationship("Page", back_populates='revisions')
Index('ix_pagerevision_pub_date', pub_date), user = relationship("User", back_populates='contributions')
)
page: Relationship[Page] = relationship("Page", back_populates='revisions')
user: Relationship[User] = relationship("User", back_populates='contributions')
textref: Relationship[PageText] = relationship("PageText") textref: Relationship[PageText] = relationship("PageText")
@ -398,13 +439,12 @@ class PageRevision(db.Model):
class PageTag(db.Model): class PageTag(db.Model):
__tablename__ = 'pagetag' __tablename__ = 'pagetag'
__table_args__ = ( __table_args__ = (
UniqueConstraint('page_id', 'name', name='pagetag_page_id_name'), UniqueConstraint('page_id', 'name'),
Index('ix_pagetag_name', 'name')
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
page_id: Mapped[int] = bound_fk(Page.id) page_id = Column(Integer, ForeignKey('page.id'))
name: Mapped[str] = mapped_column(String(64), index=True) name: Column[str] = Column(String(64), index=True)
page = relationship('Page', back_populates='tags') page = relationship('Page', back_populates='tags')
@ -415,24 +455,20 @@ class PageTag(db.Model):
class PageProperty(db.Model): class PageProperty(db.Model):
"""
XXX as of 1.1.0, PageProperty is unused
"""
__tablename__ = 'pageproperty' __tablename__ = 'pageproperty'
__table_args__ = ( __table_args__ = (
UniqueConstraint('page_id', 'key', name="pageproperty_page_id_key"), UniqueConstraint('page_id', 'key'),
) )
id: Mapped[int] = mapped_column(Integer, primary_key = True) id = Column(Integer, primary_key = True)
page_id: Mapped[int] = mapped_column(Integer, ForeignKey('page.id')) page_id = Column(Integer, ForeignKey('page.id'))
key: Mapped[str] = mapped_column(String(64)) key = Column(String(64))
value: Mapped[str] = mapped_column(String(8000)) value = Column(String(8000))
page = relationship('Page', back_populates = 'properties') page = relationship('Page', back_populates = 'properties')
# XXX PagePropertyDict? # XXX is it *really* worth it to implement PagePropertyDict?
@deprecated('is it *really* worth it to implement?')
class _PagePropertyDict(Mapping): class _PagePropertyDict(Mapping):
__slots__ = ('_page', ) __slots__ = ('_page', )
def __init__(self, page: Page, /) -> tuple[str, Any]: def __init__(self, page: Page, /) -> tuple[str, Any]:
@ -474,21 +510,18 @@ class _PagePropertyDict(Mapping):
else: else:
return self._page.get_prop(key) return self._page.get_prop(key)
class PageLink(db.Model): class PageLink(db.Model):
__tablename__ = 'pagelink' __tablename__ = 'pagelink'
id: Mapped[int] = mapped_column(Integer, primary_key = True)
from_page_id: Mapped[int] = bound_fk(Page.id)
to_page_id: Mapped[int] = bound_fk(Page.id)
__table_args__ = ( __table_args__ = (
UniqueConstraint('from_page_id', 'to_page_id', name='pagelink_from_page_id_to_page_id'), UniqueConstraint('from_page_id', 'to_page_id'),
Index('to_page_id', to_page_id)
) )
id = Column(Integer, primary_key = True)
from_page_id = Column(Integer, ForeignKey('page.id'))
to_page_id = Column(Integer, ForeignKey('page.id'))
from_page: Relationship[Page] = relationship('Page', foreign_keys=[from_page_id], back_populates='forward_links') from_page: Relationship[Page] = relationship('Page', foreign_keys=[from_page_id], back_populates='forward_links')
to_page: Relationship[Page] = relationship('Page', foreign_keys=[to_page_id], back_populates='back_links') to_page: Relationship[Page] = relationship('Page', foreign_keys=[to_page_id], back_populates='back_links')
@classmethod @classmethod
def parse_links(cls, from_page, text: str, erase=True): def parse_links(cls, from_page, text: str, erase=True):
@ -528,23 +561,20 @@ class PageLink(db.Model):
class PagePermission(db.Model): class PagePermission(db.Model):
__tablename__ = 'pagepermission' __tablename__ = 'pagepermission'
id: Mapped[int] = mapped_column(Integer, primary_key = True)
page_id: Mapped[int] = bound_fk(Page.id)
group_id: Mapped[int] = bound_fk(UserGroup.id)
# v--- will be split in 1.2, otherwise sooner or later
permissions: Mapped[int] = mapped_column(BigInteger, server_default=text('0'))
can_read: Mapped[bool] = BitSelector(permissions, PERM_READ)
can_edit: Mapped[bool] = BitSelector(permissions, PERM_EDIT)
can_create: Mapped[bool] = BitSelector(permissions, PERM_CREATE)
can_set_url: Mapped[bool] = BitSelector(permissions, PERM_SET_URL)
can_set_tags: Mapped[bool] = BitSelector(permissions, PERM_SET_TAGS)
__table_args__ = ( __table_args__ = (
UniqueConstraint('page_id', 'group_id', name="pagepermission_page_id_group_id"), UniqueConstraint('page_id', 'group_id'),
Index('group_id', group_id)
) )
id = Column(Integer, primary_key = True)
page_id = Column(Integer, ForeignKey('page.id'))
group_id = Column(Integer, ForeignKey('usergroup.id'))
permissions = Column(BigInteger, default=0)
can_read = BitSelector(permissions, PERM_READ)
can_edit = BitSelector(permissions, PERM_EDIT)
can_create = BitSelector(permissions, PERM_CREATE)
can_set_url = BitSelector(permissions, PERM_SET_URL)
can_set_tags = BitSelector(permissions, PERM_SET_TAGS)
page = relationship('Page', back_populates = 'permission_overrides') page = relationship('Page', back_populates = 'permission_overrides')
group = relationship('UserGroup', back_populates = 'page_permissions') group = relationship('UserGroup', back_populates = 'page_permissions')

View file

@ -22,7 +22,7 @@ def md_and_toc(text) -> tuple[Markup, Any | None]:
try: try:
converter: markdown.Markdown = markdown.Markdown(extensions=extensions, extension_configs=extension_configs) converter: markdown.Markdown = markdown.Markdown(extensions=extensions, extension_configs=extension_configs)
markup = Markup(converter.convert(text)) markup = Markup(converter.convert(text))
toc = Markup(converter.toc) toc = converter.toc
return markup, toc return markup, toc
except Exception as e: except Exception as e:
return error_p('Error during rendering: {0}: {1}') return error_p('Error during rendering: {0}: {1}')

View file

@ -14,7 +14,7 @@ from salvi.i18n import get_string
from ..utils import parse_tag_list from ..utils import parse_tag_list
from ..renderer import md_and_toc from ..renderer import md_and_toc
from ..models import PERM_CREATE, PERM_EDIT, Page, PageLink, PageRevision, PageText, User, db, is_url_available, is_valid_url, perms_required from ..models import PERM_CREATE, Page, PageLink, PageRevision, PageText, User, db, is_url_available, is_valid_url, perms_required
current_user: User current_user: User
@ -110,7 +110,7 @@ def create():
return savepoint(dict(url=request.args.get('url'), title='', text='', tags='', comment=get_string(g.lang, 'page_created'))) return savepoint(dict(url=request.args.get('url'), title='', text='', tags='', comment=get_string(g.lang, 'page_created')))
@bp.route('/edit/<int:id>', methods=['GET', 'POST']) @bp.route('/edit/<int:id>', methods=['GET', 'POST'])
@perms_required(PERM_EDIT, message='You are not authorized to edit pages.') @login_required
def edit(id: int): def edit(id: int):
p = db.session.execute(select(Page).where(Page.id == id)).scalar() p = db.session.execute(select(Page).where(Page.id == id)).scalar()
if p is None: if p is None:

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros/title.html" import title_tag with context %} {% from "macros/title.html" import title_tag with context %}
{% block title %}{{ title_tag(T('bad-request'), false) }}{% endblock %} {% block title %}{{ title_tag(T('Bad Request'), false) }}{% endblock %}
{% block content %} {% block content %}
<main class="error-page"> <main class="error-page">

View file

@ -25,6 +25,7 @@
}</style> }</style>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
{% block json_info %}{% endblock %} {% block json_info %}{% endblock %}
{% block ldjson %}{% endblock %}
</head> </head>
<body {% if color_theme %} class="{{ theme_classes(color_theme) }}"{% endif %}> <body {% if color_theme %} class="{{ theme_classes(color_theme) }}"{% endif %}>
<div id="__top"></div> <div id="__top"></div>
@ -62,7 +63,7 @@
</div> </div>
<footer class="site-footer"> <footer class="site-footer">
<div class="footer-copyright">&copy; 2020-2025 Sakuragasaki46.</div> <div class="footer-copyright">&copy; 2020-2025 Sakuragasaki46.</div>
<div class="footer-liability">{{ T('user-generated-warning')}} <u>{{ T('user-liability-warning') }}</u>.</div> <div class="footer-liability">Any content in this site is user-generated and <u>each user is responsible for what they publish</u>.</div>
{% if not g.no_user %} {% if not g.no_user %}
<div class="footer-loggedinas"> <div class="footer-loggedinas">
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}

View file

@ -22,13 +22,13 @@
</ul> </ul>
<p> <p>
{{ T('show-more-years') }}: Show more years:
<ul class="inline"> <ul class="inline">
<li> <li>
<a href="?till_year={{ till_year + 15 }}">{{ T('in-the-future') }}</a> <a href="?till_year={{ till_year + 15 }}">in the future</a>
</li> </li>
<li> <li>
<a href="?from_year={{ from_year - 15 }}">{{ T('in-the-past') }}</a> <a href="?from_year={{ from_year - 15 }}">in the past</a>
</li> </li>
</ul> </ul>
</p> </p>

View file

@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}List of contacts {{ app_name }}{% endblock %}
{% block content %}
<p>Showing: <strong>{{ cat }}</strong> · <a href="/kt/new">New contact</a></p>
<fieldset>
<legend>Show by:</legend>
<p><strong>Letter</strong>:
{% set typ_list = 'ABCDEFGHIJKLMNOPQRSTUVWYZ' %}
{% for t in typ_list %}
<a href="/kt/{{ t }}">{{ t }}</a> ·
{% endfor %}
</p>
<p>
<a href="/kt/">All</a> ·
<a href="/kt/expired">Expired</a> ·
<a href="/kt/ok">Sane</a>
</p>
</fieldset>
{% if count > people.count() %}
<p>Showing <strong>{{ people.count() }}</strong> people of <strong>{{ count }}</strong> total.</p>
{% if count > pageno * 50 %}
<p><a href="?page={{ pageno + 1 }}" rel="nofollow">Next page</a>{% if pageno > 1 %} · <a href="?page={{ pageno - 1 }}" rel="nofollow">Prev page</a>{% endif %}</p>
{% elif pageno > 1 %}
<a href="?page={{ pageno - 1 }}" rel="nofollow">Prev page</a></p>
{% endif %}
{% else %}
<p><strong>{{ count }}</strong> people.</p>
{% endif %}
<table class="contactnova-list">
<thead>
</thead>
<tbody>
{% for p in people %}
<tr class="contactnova-status_{{ p.status }}">
<td><span class="material-icons">{{ p.status_str() }}</span></td>
<td class="contactnova-col-code"><a href="/kt/{{ p.code }}">{{ p.code }}</a></td>
<td>{{ p.display_name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if count > people.count() %}
<p>Showing <strong>{{ people.count() }}</strong> people of <strong>{{ count }}</strong> total.</p>
{% if count > pageno * 50 %}
<p><a href="?page={{ pageno + 1 }}" rel="nofollow">Next page</a>{% if pageno > 1 %} · <a href="?page={{ pageno - 1 }}" rel="nofollow">Prev page</a>{% endif %}</p>
{% elif pageno > 1 %}
<a href="?page={{ pageno - 1 }}" rel="nofollow">Prev page</a></p>
{% endif %}
{% else %}
<p><strong>{{ count }}</strong> people.</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}Create new contact {{ app_name }}{% endblock %}
{% block content %}
<form method="POST" class="circles-add-form">
{% if returnto %}<input type="hidden" name="returnto" value="{{ returnto }}">{% endif %}
{% if not pl %}
<div>
<label>Letter</label>
<select id="ktCodeLetter" name="letter">
<option value="-" disabled="" selected="">(Choose)</option>
{% set typ_list = 'ABCDEFGHIJKLMNOPQRSTUVWYZ' %}
{% for t in typ_list %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div>
<label>Code</label>
<strong id="ktNewCode">---</strong>
</div>
<div>
<label>Display name</label>
<input name="display_name" maxlength="50"{% if pl %} value="{{ pl.display_name }}"{% endif %}>
</div>
<div>
<label>Status</label>
<select name="status">
{% set statuses = {
0: 'Variable',
1: 'OK',
2: 'Issues',
} %}
{% for k, v in statuses.items() %}
<option value="{{ k }}"{% if pl and pl.status == k %} selected=""{% endif %}>{{ v }}</option>
{% endfor %}
</select>
</div>
<div>
<label>Issues</label>
<textarea maxlength="500" name="issues">{{ pl and pl.issues }}</textarea>
</div>
<div>
<label>Description</label>
<textarea maxlength="5000" name="description">{{ pl and pl.description }}</textarea>
</div>
<div>
<label>Due</label>
<input name="due" required="" type="date" value="{{ pl.due if pl else pl_date }}" min="2020-01-01" />
</div>
<input type="submit" id="ktSubmit" value="Save">
</form>
<script>
{% if not pl %}
ktSubmit.disabled = true;
{% endif %}
ktCodeLetter.onchange = function(){
let x = new XMLHttpRequest;
x.open('GET', '/kt/_newcode/' + ktCodeLetter.value);
x.onreadystatechange = () => {
if (x.readyState === XMLHttpRequest.DONE && x.status == 200) {
ktNewCode.textContent = x.responseText;
ktSubmit.disabled = false;
}
};
x.send();
}
</script>
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Contact {{ p.code }} {{ app_name }}{% endblock %}
{% block content %}
<h1>{{ p.code }}</h1>
<p>aka: <strong>{{ p.display_name }}</strong></p>
{% if p.issues %}
<p class="contactnova-issues">{{ p.issues }}</p>
{% endif %}
<p class="contactnova-description">
{{ p.description or "No description available." |linebreaks }}
</p>
<p>&nbsp;</p>
<p>Expires: {{ p.due.strftime('%B %-d, %Y') }}</p>
<p><a href="/kt/">Back</a> · <a href="/kt/edit/{{ p.code }}?returnto=/kt/{{ p.code }}">Edit contact</a></p>
{% endblock %}

View file

@ -2,11 +2,11 @@
{% from "macros/title.html" import title_tag with context %} {% from "macros/title.html" import title_tag with context %}
{% from "macros/nl.html" import nl_list with context %} {% from "macros/nl.html" import nl_list with context %}
{% block title %}{{ title_tag(T('notes-by-date')) }}{% endblock %} {% block title %}{{ title_tag('Notes by date') }}{% endblock %}
{% block content %} {% block content %}
<main> <main>
<h1 id="firstHeading">{{ T('notes-by-date') }}</h1> <h1 id="firstHeading">Notes by date</h1>
<div class="inner-content"> <div class="inner-content">
{{ nl_list(notes) }} {{ nl_list(notes) }}

View file

@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros/title.html" import title_tag with context %} {% from "macros/title.html" import title_tag with context %}
{% block title %}{{ title_tag(T('manage-accounts'), False) }}{% endblock %} {% block title %}{{ title_tag('Manage accounts', False) }}{% endblock %}
{% block content %} {% block content %}
<h1>{{ T('manage-accounts') }}</h1> <h1>Manage accounts</h1>
<div class="inner-content"> <div class="inner-content">
{% if current_user.is_admin %} {% if current_user.is_admin %}
@ -13,7 +13,7 @@
<strong>Beware: you are managing sensitive informations.</strong> <strong>Beware: you are managing sensitive informations.</strong>
</p> </p>
<p class="nl-pagination">{{ T('search-results') }} <strong>{{ users.page * 20 - 19 }}</strong> to <strong>{{ min(users.page * 20, users.total) }}</strong> of <strong>{{ users.total }}</strong> total.</p> <p class="nl-pagination">Showing results <strong>{{ users.page * 20 - 19 }}</strong> to <strong>{{ min(users.page * 20, users.total) }}</strong> of <strong>{{ users.total }}</strong> total.</p>
<form enctype="multipart/form-data" method="POST"> <form enctype="multipart/form-data" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />

View file

@ -28,7 +28,7 @@
{% elif q %} {% elif q %}
<h2>{{ T('search-no-results') }} <em>{{ q }}</em></h2> <h2>{{ T('search-no-results') }} <em>{{ q }}</em></h2>
{% else %} {% else %}
<p><u>Please note that search queries <u>do not search for page text</u>.</u></p> <p><u>Please note that search queries do not search for page text.</u></p>
{% endif %} {% endif %}
</div> </div>
</main> </main>