feat/apikey-alias-authorship #2
@@ -223,4 +223,25 @@ def update_last_used(api_key):
|
|||||||
session.query(APIKey).filter_by(key=api_key.key).update(
|
session.query(APIKey).filter_by(key=api_key.key).update(
|
||||||
{APIKey.last_used_at: datetime.now(UTC)}
|
{APIKey.last_used_at: datetime.now(UTC)}
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_actor():
|
||||||
|
"""Identity string to record as author/last_modified_by.
|
||||||
|
|
||||||
|
- X-API-Key request -> the key's alias
|
||||||
|
- Keycloak Bearer request -> the literal 'admin' (the backend does not
|
||||||
|
track individual KC identities)
|
||||||
|
- otherwise -> None
|
||||||
|
Call only from endpoints already behind @require_auth.
|
||||||
|
"""
|
||||||
|
api_key_header = request.headers.get('X-API-Key')
|
||||||
|
if api_key_header:
|
||||||
|
api_key = get_api_key(api_key_header)
|
||||||
|
if api_key:
|
||||||
|
return api_key.alias
|
||||||
|
return None
|
||||||
|
auth_header = request.headers.get('Authorization')
|
||||||
|
if auth_header and auth_header.startswith('Bearer'):
|
||||||
|
return 'admin'
|
||||||
|
return None
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from datetime import datetime, timedelta, UTC
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from api import generate_api_key
|
from api import generate_api_key
|
||||||
from db import get_db
|
from db import get_db
|
||||||
@@ -10,13 +11,31 @@ api_key_bp = Blueprint('apikey', __name__, url_prefix='/api/apikey')
|
|||||||
# product defines, regardless of what the request body asks for.
|
# product defines, regardless of what the request body asks for.
|
||||||
ALLOWED_API_KEY_ROLES = {'admin', 'creator', 'user'}
|
ALLOWED_API_KEY_ROLES = {'admin', 'creator', 'user'}
|
||||||
|
|
||||||
|
# Validity window applied on create and on every renewal.
|
||||||
|
KEY_TTL = timedelta(days=15)
|
||||||
|
|
||||||
@api_key_bp.route('/', methods=['POST'])
|
@api_key_bp.route('/', methods=['POST'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def create_key():
|
def create_key():
|
||||||
data = request.get_json(silent=True)
|
"""Create an API key, or renew an existing one.
|
||||||
|
|
||||||
if not data or 'name' not in data:
|
`alias` is required and unique. Creating with an alias that already
|
||||||
|
exists is treated as a RENEWAL of that key: the same key string is
|
||||||
|
kept (so existing integrations keep working), its validity window is
|
||||||
|
reset, it is reactivated, and name/roles are updated. The (unchanged)
|
||||||
|
key string is returned again.
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "invalid or missing JSON body"}), 400
|
||||||
|
|
||||||
|
alias = data.get('alias')
|
||||||
|
name = data.get('name')
|
||||||
|
if not alias or not str(alias).strip():
|
||||||
|
return jsonify({"error": "alias is required"}), 400
|
||||||
|
if not name:
|
||||||
return jsonify({"error": "Name is required"}), 400
|
return jsonify({"error": "Name is required"}), 400
|
||||||
|
alias = str(alias).strip()
|
||||||
|
|
||||||
roles = data.get('roles', [])
|
roles = data.get('roles', [])
|
||||||
if not isinstance(roles, list) or any(r not in ALLOWED_API_KEY_ROLES for r in roles):
|
if not isinstance(roles, list) or any(r not in ALLOWED_API_KEY_ROLES for r in roles):
|
||||||
@@ -24,10 +43,29 @@ def create_key():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with get_db() as session:
|
with get_db() as session:
|
||||||
apikey = APIKey(key=generate_api_key(), name=data['name'], roles=roles)
|
existing = session.query(APIKey).filter_by(alias=alias).first()
|
||||||
|
if existing is not None:
|
||||||
|
# Renewal: keep the key string, reset validity, reactivate.
|
||||||
|
existing.name = name
|
||||||
|
existing.roles = roles
|
||||||
|
existing.is_active = True
|
||||||
|
existing.expire = datetime.now(UTC) + KEY_TTL
|
||||||
|
session.commit()
|
||||||
|
result = existing.to_dict()
|
||||||
|
result['renewed'] = True
|
||||||
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
apikey = APIKey(
|
||||||
|
key=generate_api_key(),
|
||||||
|
alias=alias,
|
||||||
|
name=name,
|
||||||
|
roles=roles,
|
||||||
|
expire=datetime.now(UTC) + KEY_TTL,
|
||||||
|
)
|
||||||
session.add(apikey)
|
session.add(apikey)
|
||||||
session.commit()
|
session.commit()
|
||||||
result = apikey.to_dict()
|
result = apikey.to_dict()
|
||||||
|
result['renewed'] = False
|
||||||
return jsonify(result), 201
|
return jsonify(result), 201
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from api import limiter
|
from api import limiter
|
||||||
from api import require_auth, etag_response, verify_token, is_user_admin
|
from api import require_auth, etag_response, verify_token, is_user_admin, get_actor
|
||||||
from contexts.RequestContext import RequestContext
|
from contexts.RequestContext import RequestContext
|
||||||
|
from datetime import datetime, UTC
|
||||||
from db import get_db
|
from db import get_db
|
||||||
from db.models.Markdown import Markdown
|
from db.models.Markdown import Markdown
|
||||||
from db.models.MarkdownSetting import MarkdownSetting
|
from db.models.MarkdownSetting import MarkdownSetting
|
||||||
@@ -225,7 +226,13 @@ def create_markdown():
|
|||||||
setting_id = data.get('setting_id', None)
|
setting_id = data.get('setting_id', None)
|
||||||
if not title or not content:
|
if not title or not content:
|
||||||
return jsonify({"error": "missing required fields"}), 400
|
return jsonify({"error": "missing required fields"}), 400
|
||||||
new_markdown = Markdown(title=title, content=content, path_id=path_id, shortcut=shortcut, setting_id=setting_id)
|
actor = get_actor()
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
new_markdown = Markdown(
|
||||||
|
title=title, content=content, path_id=path_id, shortcut=shortcut,
|
||||||
|
setting_id=setting_id, author=actor, last_modified_by=actor,
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
)
|
||||||
with get_db() as session:
|
with get_db() as session:
|
||||||
try:
|
try:
|
||||||
if shortcut != "":
|
if shortcut != "":
|
||||||
@@ -301,6 +308,8 @@ def update_markdown(markdown_id):
|
|||||||
markdown.shortcut = data.get('shortcut')
|
markdown.shortcut = data.get('shortcut')
|
||||||
if 'setting_id' in data:
|
if 'setting_id' in data:
|
||||||
markdown.setting_id = data.get('setting_id')
|
markdown.setting_id = data.get('setting_id')
|
||||||
|
markdown.updated_at = datetime.now(UTC)
|
||||||
|
markdown.last_modified_by = get_actor()
|
||||||
session.commit()
|
session.commit()
|
||||||
markdown_updated.send(None, payload=markdown.to_dict())
|
markdown_updated.send(None, payload=markdown.to_dict())
|
||||||
return jsonify(markdown.to_dict()), 200
|
return jsonify(markdown.to_dict()), 200
|
||||||
|
|||||||
11
api/patch.py
11
api/patch.py
@@ -1,5 +1,6 @@
|
|||||||
|
from datetime import datetime, UTC
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from api import limiter, require_auth, is_user_admin
|
from api import limiter, require_auth, is_user_admin, get_actor
|
||||||
from contexts.RequestContext import RequestContext
|
from contexts.RequestContext import RequestContext
|
||||||
from db import get_db
|
from db import get_db
|
||||||
from db.models.Markdown import Markdown
|
from db.models.Markdown import Markdown
|
||||||
@@ -65,11 +66,17 @@ def create_patch():
|
|||||||
if session.query(Markdown).get(markdown_id) is None:
|
if session.query(Markdown).get(markdown_id) is None:
|
||||||
return jsonify({"error": "markdown not found"}), 404
|
return jsonify({"error": "markdown not found"}), 404
|
||||||
try:
|
try:
|
||||||
|
actor = get_actor()
|
||||||
|
now = datetime.now(UTC)
|
||||||
patch = MarkdownPatch(
|
patch = MarkdownPatch(
|
||||||
markdown_id=markdown_id,
|
markdown_id=markdown_id,
|
||||||
title=data.get('title'),
|
title=data.get('title'),
|
||||||
content=content,
|
content=content,
|
||||||
order=data.get('order', 0),
|
order=data.get('order', 0),
|
||||||
|
author=actor,
|
||||||
|
last_modified_by=actor,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
)
|
)
|
||||||
session.add(patch)
|
session.add(patch)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -104,6 +111,8 @@ def update_patch(patch_id):
|
|||||||
patch.content = data.get('content')
|
patch.content = data.get('content')
|
||||||
if 'order' in data:
|
if 'order' in data:
|
||||||
patch.order = data.get('order')
|
patch.order = data.get('order')
|
||||||
|
patch.updated_at = datetime.now(UTC)
|
||||||
|
patch.last_modified_by = get_actor()
|
||||||
session.commit()
|
session.commit()
|
||||||
return jsonify(patch.to_dict()), 200
|
return jsonify(patch.to_dict()), 200
|
||||||
|
|
||||||
|
|||||||
@@ -59,12 +59,71 @@ def init_payload():
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(conn, table, column):
|
||||||
|
row = conn.execute(text(
|
||||||
|
"SELECT 1 FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = :db AND table_name = :t AND column_name = :c"
|
||||||
|
), {"db": DB_NAME, "t": table, "c": column}).first()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(conn, table, index):
|
||||||
|
row = conn.execute(text(
|
||||||
|
"SELECT 1 FROM information_schema.statistics "
|
||||||
|
"WHERE table_schema = :db AND table_name = :t AND index_name = :i"
|
||||||
|
), {"db": DB_NAME, "t": table, "i": index}).first()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations():
|
||||||
|
"""Idempotent additive schema migrations for already-existing tables.
|
||||||
|
|
||||||
|
create_all() creates missing tables (with the new columns) for a fresh
|
||||||
|
DB, but never alters existing ones. This adds the new columns to legacy
|
||||||
|
tables and backfills sensible defaults. Safe to run on every startup.
|
||||||
|
"""
|
||||||
|
# (table, column, DDL, backfill SQL or None)
|
||||||
|
steps = [
|
||||||
|
("apikey", "alias", "ALTER TABLE apikey ADD COLUMN alias VARCHAR(255) NULL",
|
||||||
|
"UPDATE apikey SET alias = `key` WHERE alias IS NULL"),
|
||||||
|
("markdown", "updated_at", "ALTER TABLE markdown ADD COLUMN updated_at DATETIME NULL",
|
||||||
|
"UPDATE markdown SET updated_at = created_at WHERE updated_at IS NULL"),
|
||||||
|
("markdown", "author", "ALTER TABLE markdown ADD COLUMN author VARCHAR(255) NULL",
|
||||||
|
"UPDATE markdown SET author = 'admin' WHERE author IS NULL"),
|
||||||
|
("markdown", "last_modified_by", "ALTER TABLE markdown ADD COLUMN last_modified_by VARCHAR(255) NULL",
|
||||||
|
"UPDATE markdown SET last_modified_by = 'admin' WHERE last_modified_by IS NULL"),
|
||||||
|
("markdown_patch", "author", "ALTER TABLE markdown_patch ADD COLUMN author VARCHAR(255) NULL",
|
||||||
|
"UPDATE markdown_patch SET author = 'admin' WHERE author IS NULL"),
|
||||||
|
("markdown_patch", "last_modified_by", "ALTER TABLE markdown_patch ADD COLUMN last_modified_by VARCHAR(255) NULL",
|
||||||
|
"UPDATE markdown_patch SET last_modified_by = 'admin' WHERE last_modified_by IS NULL"),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for table, column, ddl, backfill in steps:
|
||||||
|
if not _column_exists(conn, table, column):
|
||||||
|
conn.execute(text(ddl))
|
||||||
|
if backfill:
|
||||||
|
conn.execute(text(backfill))
|
||||||
|
print(f"[ x ] migrated {table}.{column}")
|
||||||
|
# Unique constraint on apikey.alias once it is populated.
|
||||||
|
if not _index_exists(conn, "apikey", "uq_apikey_alias"):
|
||||||
|
conn.execute(text(
|
||||||
|
"ALTER TABLE apikey ADD CONSTRAINT uq_apikey_alias UNIQUE (alias)"
|
||||||
|
))
|
||||||
|
print("[ x ] migrated apikey.alias unique constraint")
|
||||||
|
except Exception as e:
|
||||||
|
# Don't block startup on a migration hiccup; surface loudly.
|
||||||
|
print(f"[ ! ] run_migrations error (continuing): {e}")
|
||||||
|
|
||||||
|
|
||||||
def setup_db():
|
def setup_db():
|
||||||
if DB_SCHEMA_UPDATED:
|
if DB_SCHEMA_UPDATED:
|
||||||
clear_db()
|
clear_db()
|
||||||
print("[ x ] db cleared")
|
print("[ x ] db cleared")
|
||||||
create_all()
|
create_all()
|
||||||
print("[ x ] db created")
|
print("[ x ] db created")
|
||||||
|
run_migrations()
|
||||||
|
print("[ x ] db migrations applied")
|
||||||
run_scripts()
|
run_scripts()
|
||||||
print("[ x ] db scripts executed")
|
print("[ x ] db scripts executed")
|
||||||
init_payload()
|
init_payload()
|
||||||
|
|||||||
79
db/migrations/2026-05-16_apikey_alias_authorship.sql
Normal file
79
db/migrations/2026-05-16_apikey_alias_authorship.sql
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Production migration: apikey.alias (unique) + markdown/patch authorship
|
||||||
|
-- ============================================================================
|
||||||
|
-- Idempotent. Safe to run multiple times. Target: MySQL 8 (no native
|
||||||
|
-- ADD COLUMN IF NOT EXISTS, so columns are guarded via information_schema
|
||||||
|
-- + a prepared statement). Mirrors the app's db.run_migrations(); running
|
||||||
|
-- both is harmless.
|
||||||
|
--
|
||||||
|
-- Apply against the application schema, e.g.:
|
||||||
|
-- docker exec -i mysql sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" hangmanlab' \
|
||||||
|
-- < 2026-05-16_apikey_alias_authorship.sql
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
SET @schema := DATABASE();
|
||||||
|
|
||||||
|
-- ---- helper macro is not available in plain SQL; repeat the guarded block --
|
||||||
|
|
||||||
|
-- apikey.alias --------------------------------------------------------------
|
||||||
|
SET @c := (SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
WHERE table_schema=@schema AND table_name='apikey' AND column_name='alias');
|
||||||
|
SET @ddl := IF(@c=0,
|
||||||
|
'ALTER TABLE apikey ADD COLUMN alias VARCHAR(255) NULL',
|
||||||
|
'DO 0');
|
||||||
|
PREPARE st FROM @ddl; EXECUTE st; DEALLOCATE PREPARE st;
|
||||||
|
-- backfill: existing keys get alias = their (unique) key string
|
||||||
|
UPDATE apikey SET alias = `key` WHERE alias IS NULL;
|
||||||
|
|
||||||
|
-- apikey unique constraint on alias -----------------------------------------
|
||||||
|
SET @i := (SELECT COUNT(*) FROM information_schema.statistics
|
||||||
|
WHERE table_schema=@schema AND table_name='apikey' AND index_name='uq_apikey_alias');
|
||||||
|
SET @ddl := IF(@i=0,
|
||||||
|
'ALTER TABLE apikey ADD CONSTRAINT uq_apikey_alias UNIQUE (alias)',
|
||||||
|
'DO 0');
|
||||||
|
PREPARE st FROM @ddl; EXECUTE st; DEALLOCATE PREPARE st;
|
||||||
|
|
||||||
|
-- markdown.updated_at -------------------------------------------------------
|
||||||
|
SET @c := (SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
WHERE table_schema=@schema AND table_name='markdown' AND column_name='updated_at');
|
||||||
|
SET @ddl := IF(@c=0,
|
||||||
|
'ALTER TABLE markdown ADD COLUMN updated_at DATETIME NULL',
|
||||||
|
'DO 0');
|
||||||
|
PREPARE st FROM @ddl; EXECUTE st; DEALLOCATE PREPARE st;
|
||||||
|
UPDATE markdown SET updated_at = created_at WHERE updated_at IS NULL;
|
||||||
|
|
||||||
|
-- markdown.author -----------------------------------------------------------
|
||||||
|
SET @c := (SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
WHERE table_schema=@schema AND table_name='markdown' AND column_name='author');
|
||||||
|
SET @ddl := IF(@c=0,
|
||||||
|
'ALTER TABLE markdown ADD COLUMN author VARCHAR(255) NULL',
|
||||||
|
'DO 0');
|
||||||
|
PREPARE st FROM @ddl; EXECUTE st; DEALLOCATE PREPARE st;
|
||||||
|
UPDATE markdown SET author = 'admin' WHERE author IS NULL;
|
||||||
|
|
||||||
|
-- markdown.last_modified_by -------------------------------------------------
|
||||||
|
SET @c := (SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
WHERE table_schema=@schema AND table_name='markdown' AND column_name='last_modified_by');
|
||||||
|
SET @ddl := IF(@c=0,
|
||||||
|
'ALTER TABLE markdown ADD COLUMN last_modified_by VARCHAR(255) NULL',
|
||||||
|
'DO 0');
|
||||||
|
PREPARE st FROM @ddl; EXECUTE st; DEALLOCATE PREPARE st;
|
||||||
|
UPDATE markdown SET last_modified_by = 'admin' WHERE last_modified_by IS NULL;
|
||||||
|
|
||||||
|
-- markdown_patch.author -----------------------------------------------------
|
||||||
|
SET @c := (SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
WHERE table_schema=@schema AND table_name='markdown_patch' AND column_name='author');
|
||||||
|
SET @ddl := IF(@c=0,
|
||||||
|
'ALTER TABLE markdown_patch ADD COLUMN author VARCHAR(255) NULL',
|
||||||
|
'DO 0');
|
||||||
|
PREPARE st FROM @ddl; EXECUTE st; DEALLOCATE PREPARE st;
|
||||||
|
UPDATE markdown_patch SET author = 'admin' WHERE author IS NULL;
|
||||||
|
|
||||||
|
-- markdown_patch.last_modified_by -------------------------------------------
|
||||||
|
SET @c := (SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
WHERE table_schema=@schema AND table_name='markdown_patch' AND column_name='last_modified_by');
|
||||||
|
SET @ddl := IF(@c=0,
|
||||||
|
'ALTER TABLE markdown_patch ADD COLUMN last_modified_by VARCHAR(255) NULL',
|
||||||
|
'DO 0');
|
||||||
|
PREPARE st FROM @ddl; EXECUTE st; DEALLOCATE PREPARE st;
|
||||||
|
UPDATE markdown_patch SET last_modified_by = 'admin' WHERE last_modified_by IS NULL;
|
||||||
@@ -6,6 +6,9 @@ class APIKey(Base):
|
|||||||
__tablename__ = 'apikey'
|
__tablename__ = 'apikey'
|
||||||
|
|
||||||
key = Column(String(64), primary_key=True)
|
key = Column(String(64), primary_key=True)
|
||||||
|
# Stable human identity of the key. Unique; creating with an existing
|
||||||
|
# alias is treated as a renewal of that key (see api/apikey).
|
||||||
|
alias = Column(String(255), nullable=False, unique=True)
|
||||||
name = Column(String(255), nullable=False)
|
name = Column(String(255), nullable=False)
|
||||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||||
last_used_at = Column(DateTime)
|
last_used_at = Column(DateTime)
|
||||||
@@ -16,6 +19,7 @@ class APIKey(Base):
|
|||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
"key": self.key,
|
"key": self.key,
|
||||||
|
"alias": self.alias,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
|
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ class Markdown(Base):
|
|||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
title = Column(String(255), nullable=False)
|
title = Column(String(255), nullable=False)
|
||||||
content = Column(Text, nullable=False)
|
content = Column(Text, nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.datetime.now(datetime.UTC))
|
created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.UTC))
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime,
|
||||||
|
default=lambda: datetime.datetime.now(datetime.UTC),
|
||||||
|
onupdate=lambda: datetime.datetime.now(datetime.UTC),
|
||||||
|
)
|
||||||
|
# Actor strings: alias of the API key, or 'admin' for KC-logged-in UI.
|
||||||
|
author = Column(String(255), nullable=True)
|
||||||
|
last_modified_by = Column(String(255), nullable=True)
|
||||||
path_id = Column(Integer, ForeignKey('path.id'), nullable=False)
|
path_id = Column(Integer, ForeignKey('path.id'), nullable=False)
|
||||||
order = Column(String(36), default=lambda: str(uuid.uuid4()))
|
order = Column(String(36), default=lambda: str(uuid.uuid4()))
|
||||||
shortcut = Column(String(36), default="")
|
shortcut = Column(String(36), default="")
|
||||||
@@ -21,6 +29,9 @@ class Markdown(Base):
|
|||||||
'title': self.title,
|
'title': self.title,
|
||||||
'content': self.content,
|
'content': self.content,
|
||||||
'created_at': self.created_at,
|
'created_at': self.created_at,
|
||||||
|
'updated_at': self.updated_at,
|
||||||
|
'author': self.author,
|
||||||
|
'last_modified_by': self.last_modified_by,
|
||||||
'path_id': self.path_id,
|
'path_id': self.path_id,
|
||||||
'order': self.order,
|
'order': self.order,
|
||||||
'shortcut': self.shortcut,
|
'shortcut': self.shortcut,
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class MarkdownPatch(Base):
|
|||||||
)
|
)
|
||||||
title = Column(String(255), nullable=True)
|
title = Column(String(255), nullable=True)
|
||||||
content = Column(Text, nullable=False)
|
content = Column(Text, nullable=False)
|
||||||
|
# Actor strings: alias of the API key, or 'admin' for KC-logged-in UI.
|
||||||
|
author = Column(String(255), nullable=True)
|
||||||
|
last_modified_by = Column(String(255), nullable=True)
|
||||||
order = Column(Integer, default=0)
|
order = Column(Integer, default=0)
|
||||||
created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.UTC))
|
created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.UTC))
|
||||||
updated_at = Column(
|
updated_at = Column(
|
||||||
@@ -31,6 +34,8 @@ class MarkdownPatch(Base):
|
|||||||
'markdown_id': self.markdown_id,
|
'markdown_id': self.markdown_id,
|
||||||
'title': self.title,
|
'title': self.title,
|
||||||
'content': self.content,
|
'content': self.content,
|
||||||
|
'author': self.author,
|
||||||
|
'last_modified_by': self.last_modified_by,
|
||||||
'order': self.order,
|
'order': self.order,
|
||||||
'created_at': self.created_at,
|
'created_at': self.created_at,
|
||||||
'updated_at': self.updated_at,
|
'updated_at': self.updated_at,
|
||||||
|
|||||||
Reference in New Issue
Block a user