115 lines
5.2 KiB
Python
Executable File
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)
|