fn_b/tools/editorial/weekly.py

115 lines
5.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Weekly writing quota and missed-window recovery; never publishes by itself."""
import argparse
from datetime import datetime, timedelta
import fcntl
import json
from pathlib import Path
import secrets
import sys
from zoneinfo import ZoneInfo
from release import atomic_json
ZONE = ZoneInfo('Asia/Shanghai')
STATE = Path(__file__).resolve().parents[2] / 'storage/app/private/weekly-tutorials/state.json'
def week_start(now):
return (now - timedelta(days=now.weekday())).date().isoformat()
def new_week(key):
monday = datetime.fromisoformat(key).replace(tzinfo=ZONE)
while True:
times = [monday + timedelta(days=secrets.choice(days), hours=15)
for days in ([0, 1], [2, 3], [4, 5, 6])]
if all(b - a >= timedelta(hours=24) for a, b in zip(times, times[1:])):
return {'slots': [{'slot_id': f'{key}-{i + 1}', 'planned_at': t.isoformat(), 'status': 'planned',
'release_id': None, 'published_at': None, 'urls': []} for i, t in enumerate(times)]}
def published_slots(state):
return [s for w in state['weeks'].values() for s in w['slots'] if s.get('published_at')]
def due(state, now):
if now.date().isoformat() < state['starts_on']:
return {'action': 'wait', 'reason': 'not_started'}
key = week_start(now)
state['weeks'].setdefault(key, new_week(key))
published = published_slots(state)
for slot in published:
if slot['status'] == 'published_pending':
return {'action': 'synchronize', 'slot': slot}
if any(datetime.fromisoformat(s['published_at']).astimezone(ZONE).date() == now.date() for s in published):
return {'action': 'wait', 'reason': 'already_published_today'}
# Uncertain old work must be reconciled before creating a new release or applying the quota.
for week in sorted(state['weeks']):
for slot in state['weeks'][week]['slots']:
if slot.get('release_id') and not slot.get('published_at') and slot['status'] != 'abandoned':
return {'action': 'resume', 'slot': slot}
count = sum(week_start(datetime.fromisoformat(s['published_at']).astimezone(ZONE)) == key for s in published)
if count >= 3:
return {'action': 'wait', 'reason': 'weekly_quota_met'}
for slot in state['weeks'][key]['slots']:
if slot['status'] == 'planned' and datetime.fromisoformat(slot['planned_at']) <= now:
return {'action': 'write', 'slot': slot}
return {'action': 'wait', 'reason': 'not_due'}
def record(state, slot_id, response, now):
if response.get('ok') is not True:
raise ValueError('No successful server response')
data = response['data']
receipt = data.get('receipt', {})
if receipt.get('status') != 'published' or data.get('phase') not in ('complete', 'published_pending'):
raise ValueError('No committed publication receipt')
slot = next(s for w in state['weeks'].values() for s in w['slots'] if s['slot_id'] == slot_id)
if slot.get('release_id') != data['release_id']:
raise ValueError('Release does not match claimed slot')
# Record actual server transaction timestamp, not the time a delayed response was received.
published_at = data.get('published_at')
if not published_at:
raise ValueError('Missing server publication timestamp')
instant = datetime.fromisoformat(published_at).astimezone(ZONE)
if instant > now + timedelta(minutes=5):
raise ValueError('Publication timestamp is in the future')
slot.update(status='published' if data['phase'] == 'complete' else 'published_pending',
published_at=instant.isoformat(), publication_id=receipt['publication_id'],
urls=[a['url'] for a in receipt['articles']], receipt_phase=data['phase'])
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('operation', choices=['due', 'claim', 'record'])
parser.add_argument('--slot')
parser.add_argument('--release-id')
parser.add_argument('--receipt', type=Path)
args = parser.parse_args()
now = datetime.now(ZONE)
STATE.parent.mkdir(parents=True, exist_ok=True)
with open(STATE.with_suffix('.lock'), 'a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
# Missing/corrupt state must be reconciled with server receipts, not reset to an empty quota.
state = json.loads(STATE.read_text())
result = due(state, now)
if args.operation == 'claim':
import uuid
release_id = str(uuid.UUID(args.release_id))
if result['action'] != 'write' or result['slot']['slot_id'] != args.slot:
raise ValueError('Requested slot is not due')
result['slot'].update(status='drafting', release_id=release_id)
elif args.operation == 'record':
record(state, args.slot, json.loads(args.receipt.read_text()), now)
result = {'action': 'recorded', 'slot_id': args.slot}
atomic_json(STATE, state)
print(json.dumps(result, ensure_ascii=False))
if __name__ == '__main__':
try:
main()
except Exception as error:
print(json.dumps({'action': 'blocked', 'reason': str(error)}))
sys.exit(1)