tenderkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tenderkit/__init__.py +9 -0
- tenderkit/__main__.py +5 -0
- tenderkit/cli.py +248 -0
- tenderkit/documents.py +260 -0
- tenderkit/errors.py +52 -0
- tenderkit/http.py +94 -0
- tenderkit/interfaces.py +42 -0
- tenderkit/limits.py +7 -0
- tenderkit/mcp.py +221 -0
- tenderkit/model.py +78 -0
- tenderkit/pdf_ocr.py +78 -0
- tenderkit/platforms/__init__.py +33 -0
- tenderkit/platforms/backends.py +163 -0
- tenderkit/platforms/base.py +79 -0
- tenderkit/platforms/bk.py +78 -0
- tenderkit/platforms/ezamowienia.py +85 -0
- tenderkit/platforms/html.py +67 -0
- tenderkit/platforms/marketplanet_html.py +128 -0
- tenderkit/platforms/public_documents.py +206 -0
- tenderkit/platforms/pz_html.py +127 -0
- tenderkit/platforms/session.py +114 -0
- tenderkit/profile.py +216 -0
- tenderkit/profiles/__init__.py +13 -0
- tenderkit/profiles/leki.toml +5 -0
- tenderkit/profiles/oprogramowanie.toml +4 -0
- tenderkit/profiles/wyroby-medyczne.toml +4 -0
- tenderkit/queue.py +126 -0
- tenderkit/read_document.py +309 -0
- tenderkit/serialization.py +63 -0
- tenderkit/service.py +164 -0
- tenderkit/sources/__init__.py +1 -0
- tenderkit/sources/bk.py +145 -0
- tenderkit/sources/bzp.py +142 -0
- tenderkit/sources/mapping.py +264 -0
- tenderkit/sources/ted.py +116 -0
- tenderkit/sources/ted_xml.py +97 -0
- tenderkit/store/__init__.py +1 -0
- tenderkit/store/documents.py +168 -0
- tenderkit/store/state.py +202 -0
- tenderkit/ui.py +243 -0
- tenderkit/watch.py +247 -0
- tenderkit/web/index.html +59 -0
- tenderkit/web/style.css +221 -0
- tenderkit-0.1.0.dist-info/METADATA +219 -0
- tenderkit-0.1.0.dist-info/RECORD +49 -0
- tenderkit-0.1.0.dist-info/WHEEL +5 -0
- tenderkit-0.1.0.dist-info/entry_points.txt +2 -0
- tenderkit-0.1.0.dist-info/licenses/LICENSE +202 -0
- tenderkit-0.1.0.dist-info/top_level.txt +1 -0
tenderkit/store/state.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""SQLite state: one transaction per successfully completed source window."""
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sqlite3
|
|
7
|
+
|
|
8
|
+
from tenderkit.serialization import dumps, notice_from_record, notice_record
|
|
9
|
+
|
|
10
|
+
SCHEMA = '''
|
|
11
|
+
create table if not exists notices (
|
|
12
|
+
source text not null, number text not null, published text,
|
|
13
|
+
payload text not null, coverage text not null default 'PENDING',
|
|
14
|
+
primary key(source, number)
|
|
15
|
+
);
|
|
16
|
+
create index if not exists notices_published on notices(published, source, number);
|
|
17
|
+
create table if not exists refreshes (
|
|
18
|
+
id integer primary key, source text not null, start text not null, end text not null,
|
|
19
|
+
query text not null, status text not null, count integer not null,
|
|
20
|
+
error text, finished_at text not null
|
|
21
|
+
);
|
|
22
|
+
create table if not exists document_refs (
|
|
23
|
+
source text not null, number text not null, document_key text not null, payload text not null,
|
|
24
|
+
primary key(source,number,document_key)
|
|
25
|
+
);
|
|
26
|
+
create table if not exists procedure_links (
|
|
27
|
+
source text not null, number text not null, payload text not null,
|
|
28
|
+
primary key(source,number)
|
|
29
|
+
);
|
|
30
|
+
create table if not exists notice_documents (
|
|
31
|
+
source text not null, number text not null, document_key text not null, sha256 text not null,
|
|
32
|
+
payload text not null, fetched_at text not null,
|
|
33
|
+
primary key(source,number,document_key,sha256)
|
|
34
|
+
);
|
|
35
|
+
-- A document the platform refused with a permanent HTTP error (a withdrawn or replaced attachment). A whole-procedure
|
|
36
|
+
-- download skips it; asking for its key again retries it, and a later successful download clears it.
|
|
37
|
+
create table if not exists document_unavailable (
|
|
38
|
+
source text not null, number text not null, document_key text not null, error text not null, checked_at text not null,
|
|
39
|
+
primary key(source,number,document_key)
|
|
40
|
+
);
|
|
41
|
+
create table if not exists fetch_jobs (
|
|
42
|
+
id integer primary key, source text not null, number text not null, selection text,
|
|
43
|
+
status text not null, attempts integer not null default 0, error text, error_kind text,
|
|
44
|
+
next_attempt real not null default 0, heartbeat real, summary text not null default '{}'
|
|
45
|
+
);
|
|
46
|
+
create unique index if not exists active_document_job on fetch_jobs(source,number)
|
|
47
|
+
where status in ('QUEUED','RUNNING');
|
|
48
|
+
create table if not exists watch_cursors (
|
|
49
|
+
profile text not null, source text not null, resume_from text not null,
|
|
50
|
+
primary key(profile,source)
|
|
51
|
+
);
|
|
52
|
+
create table if not exists watch_seen (
|
|
53
|
+
profile text not null, source text not null, identity text not null,
|
|
54
|
+
revision text not null, deadline text, primary key(profile,source,identity)
|
|
55
|
+
);
|
|
56
|
+
create table if not exists watch_ted_aliases (
|
|
57
|
+
profile text not null, alias text not null, identity text not null, primary key(profile,alias)
|
|
58
|
+
);
|
|
59
|
+
create table if not exists watch_runs (
|
|
60
|
+
id integer primary key, profile text not null, finished_at text not null, sources text not null
|
|
61
|
+
);
|
|
62
|
+
create index if not exists watch_runs_profile on watch_runs(profile,id);
|
|
63
|
+
create table if not exists watch_events (
|
|
64
|
+
id integer primary key, run_id integer not null references watch_runs(id),
|
|
65
|
+
profile text not null, payload text not null, delivered integer not null default 0
|
|
66
|
+
);
|
|
67
|
+
create index if not exists watch_events_run on watch_events(run_id,id);
|
|
68
|
+
create index if not exists watch_events_pending on watch_events(profile) where delivered=0;
|
|
69
|
+
'''
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class State:
|
|
73
|
+
def __init__(self, path, *, read_only=False):
|
|
74
|
+
self.path = Path(path).expanduser()
|
|
75
|
+
self.read_only = read_only
|
|
76
|
+
if read_only:
|
|
77
|
+
return
|
|
78
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
with self.connection() as conn:
|
|
80
|
+
conn.executescript(SCHEMA)
|
|
81
|
+
|
|
82
|
+
@contextmanager
|
|
83
|
+
def connection(self):
|
|
84
|
+
database = self.path.resolve().as_uri() + '?mode=ro' if self.read_only else self.path
|
|
85
|
+
conn = sqlite3.connect(database, timeout=30, uri=self.read_only)
|
|
86
|
+
conn.row_factory = sqlite3.Row
|
|
87
|
+
try:
|
|
88
|
+
with conn:
|
|
89
|
+
yield conn
|
|
90
|
+
finally:
|
|
91
|
+
conn.close()
|
|
92
|
+
|
|
93
|
+
def record_refresh(self, source, start, end, query, notices=None, error=None):
|
|
94
|
+
with self.connection() as conn:
|
|
95
|
+
for notice in notices or []:
|
|
96
|
+
conn.execute('''insert into notices(source,number,published,payload) values(?,?,?,?)
|
|
97
|
+
on conflict(source,number) do update set published=excluded.published,payload=excluded.payload''',
|
|
98
|
+
(notice.source, notice.number, str(notice.published) if notice.published else None,
|
|
99
|
+
dumps(notice_record(notice))))
|
|
100
|
+
conn.execute('''insert into refreshes(source,start,end,query,status,count,error,finished_at)
|
|
101
|
+
values(?,?,?,?,?,?,?,?)''', (source, str(start), str(end), dumps(query),
|
|
102
|
+
'FAILED' if error else 'COMPLETED', len(notices or []), error,
|
|
103
|
+
datetime.now(timezone.utc).isoformat()))
|
|
104
|
+
|
|
105
|
+
def notices(self, start=None, end=None, sources=()):
|
|
106
|
+
conditions, params = [], []
|
|
107
|
+
if start:
|
|
108
|
+
conditions.append('published >= ?')
|
|
109
|
+
params.append(str(start))
|
|
110
|
+
if end:
|
|
111
|
+
conditions.append('published <= ?')
|
|
112
|
+
params.append(str(end))
|
|
113
|
+
if sources:
|
|
114
|
+
conditions.append('source in (' + ','.join('?' for _ in sources) + ')')
|
|
115
|
+
params.extend(sources)
|
|
116
|
+
where = ' where ' + ' and '.join(conditions) if conditions else ''
|
|
117
|
+
with self.connection() as conn:
|
|
118
|
+
for row in conn.execute('select payload from notices' + where + ' order by published desc,source,number', params):
|
|
119
|
+
yield notice_from_record(json.loads(row['payload']))
|
|
120
|
+
|
|
121
|
+
def get(self, source, number):
|
|
122
|
+
with self.connection() as conn:
|
|
123
|
+
row = conn.execute('select payload from notices where source=? and number=?', (source, number)).fetchone()
|
|
124
|
+
return notice_from_record(json.loads(row['payload'])) if row else None
|
|
125
|
+
|
|
126
|
+
def coverage(self):
|
|
127
|
+
with self.connection() as conn:
|
|
128
|
+
counts = [dict(r) for r in conn.execute('select source,coverage,count(*) as count from notices group by source,coverage')]
|
|
129
|
+
# Every source/query window is explicit; this is not a claim of complete catalogue coverage.
|
|
130
|
+
refreshes = [dict(r) for r in conn.execute('select * from refreshes order by id desc limit 50')]
|
|
131
|
+
for row in refreshes:
|
|
132
|
+
row['query'] = json.loads(row['query'])
|
|
133
|
+
jobs = [dict(r) for r in conn.execute('select id,source,number,status,attempts,error,error_kind,next_attempt,summary from fetch_jobs order by id desc limit 50')]
|
|
134
|
+
for job in jobs:
|
|
135
|
+
job['summary'] = json.loads(job['summary'])
|
|
136
|
+
return dict(counts=counts, refreshes=refreshes, jobs=jobs)
|
|
137
|
+
|
|
138
|
+
def set_coverage(self, source, number, status):
|
|
139
|
+
if status not in ('PENDING', 'FETCHED', 'NO_DOCUMENTS', 'UNSUPPORTED_PLATFORM', 'NO_REFERENCE', 'FETCH_FAILED'):
|
|
140
|
+
raise ValueError('Invalid coverage state')
|
|
141
|
+
with self.connection() as conn:
|
|
142
|
+
conn.execute('update notices set coverage=? where source=? and number=?', (status, source, number))
|
|
143
|
+
|
|
144
|
+
def save_listing(self, source, number, refs, link=None):
|
|
145
|
+
from dataclasses import asdict
|
|
146
|
+
with self.connection() as conn:
|
|
147
|
+
conn.execute('delete from document_refs where source=? and number=?', (source, number))
|
|
148
|
+
conn.executemany('insert into document_refs values(?,?,?,?)',
|
|
149
|
+
[(source, number, ref.key, dumps(asdict(ref))) for ref in refs])
|
|
150
|
+
if link is not None:
|
|
151
|
+
conn.execute('insert into procedure_links values(?,?,?) on conflict(source,number) do update set payload=excluded.payload',
|
|
152
|
+
(source, number, dumps(asdict(link))))
|
|
153
|
+
|
|
154
|
+
def procedure_link(self, source, number):
|
|
155
|
+
with self.connection() as conn:
|
|
156
|
+
row = conn.execute('select payload from procedure_links where source=? and number=?', (source, number)).fetchone()
|
|
157
|
+
return json.loads(row[0]) if row else None
|
|
158
|
+
|
|
159
|
+
def listing(self, source, number):
|
|
160
|
+
with self.connection() as conn:
|
|
161
|
+
return [json.loads(row[0]) for row in conn.execute(
|
|
162
|
+
'select payload from document_refs where source=? and number=? order by rowid', (source, number))]
|
|
163
|
+
|
|
164
|
+
def record_document(self, source, number, record):
|
|
165
|
+
with self.connection() as conn:
|
|
166
|
+
conn.execute('''insert into notice_documents values(?,?,?,?,?,?)
|
|
167
|
+
on conflict(source,number,document_key,sha256) do update set payload=excluded.payload,fetched_at=excluded.fetched_at''',
|
|
168
|
+
(source, number, record['key'], record['sha256'], dumps(record), record['fetched_at']))
|
|
169
|
+
|
|
170
|
+
def mark_unavailable(self, source, number, key, error):
|
|
171
|
+
with self.connection() as conn:
|
|
172
|
+
conn.execute('''insert into document_unavailable values(?,?,?,?,?)
|
|
173
|
+
on conflict(source,number,document_key) do update set error=excluded.error,checked_at=excluded.checked_at''',
|
|
174
|
+
(source, number, key, error, datetime.now(timezone.utc).isoformat()))
|
|
175
|
+
|
|
176
|
+
def clear_unavailable(self, source, number, key):
|
|
177
|
+
with self.connection() as conn:
|
|
178
|
+
conn.execute('delete from document_unavailable where source=? and number=? and document_key=?', (source, number, key))
|
|
179
|
+
|
|
180
|
+
def unavailable(self, source, number):
|
|
181
|
+
"""{document key: {error, checked_at}} of the documents the platform refused for good."""
|
|
182
|
+
with self.connection() as conn:
|
|
183
|
+
return {row['document_key']: dict(error=row['error'], checked_at=row['checked_at']) for row in conn.execute(
|
|
184
|
+
'select document_key,error,checked_at from document_unavailable where source=? and number=?', (source, number))}
|
|
185
|
+
|
|
186
|
+
def documents(self, source, number):
|
|
187
|
+
with self.connection() as conn:
|
|
188
|
+
# Return latest versions by key; earlier hashes stay registered and readable.
|
|
189
|
+
found = {}
|
|
190
|
+
for row in conn.execute('select document_key,payload from notice_documents where source=? and number=? order by fetched_at,rowid', (source, number)):
|
|
191
|
+
found[row['document_key']] = json.loads(row['payload'])
|
|
192
|
+
return found
|
|
193
|
+
|
|
194
|
+
def document(self, digest):
|
|
195
|
+
import re
|
|
196
|
+
if not re.fullmatch(r'[a-f0-9]{64}', digest or ''):
|
|
197
|
+
raise ValueError('Document id must be a SHA-256 digest')
|
|
198
|
+
with self.connection() as conn:
|
|
199
|
+
row = conn.execute('select source,number,payload from notice_documents where sha256=? order by fetched_at desc limit 1', (digest,)).fetchone()
|
|
200
|
+
if row is None:
|
|
201
|
+
raise ValueError('Document not found in local state')
|
|
202
|
+
return dict(source=row['source'], number=row['number'], **json.loads(row['payload']))
|
tenderkit/ui.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Loopback HTTP view over the same saved notices and profiles as the CLI."""
|
|
2
|
+
from dataclasses import replace
|
|
3
|
+
from datetime import date, datetime, timezone
|
|
4
|
+
from html import escape
|
|
5
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
import re
|
|
8
|
+
import sqlite3
|
|
9
|
+
from string import Template
|
|
10
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit
|
|
11
|
+
from zoneinfo import ZoneInfo
|
|
12
|
+
|
|
13
|
+
from tenderkit.profile import fold
|
|
14
|
+
from tenderkit.service import filtered
|
|
15
|
+
from tenderkit.store.state import State
|
|
16
|
+
|
|
17
|
+
PAGE_SIZE = 30
|
|
18
|
+
PROFILE_LABELS = {
|
|
19
|
+
'oprogramowanie': 'Oprogramowanie',
|
|
20
|
+
'leki': 'Leki',
|
|
21
|
+
'wyroby-medyczne': 'Wyroby medyczne',
|
|
22
|
+
}
|
|
23
|
+
DEADLINE_LABELS = {
|
|
24
|
+
'': 'Dowolny',
|
|
25
|
+
'upcoming': 'Przed terminem',
|
|
26
|
+
'passed': 'Po terminie',
|
|
27
|
+
'today': 'Dziś, godzina nieznana',
|
|
28
|
+
'missing': 'Brak terminu',
|
|
29
|
+
}
|
|
30
|
+
SORT_LABELS = {
|
|
31
|
+
'newest': 'Najnowsze',
|
|
32
|
+
'oldest': 'Najstarsze',
|
|
33
|
+
'deadline': 'Termin rosnąco',
|
|
34
|
+
'title': 'Tytuł A–Z',
|
|
35
|
+
'buyer': 'Zamawiający A–Z',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def page_link(params):
|
|
40
|
+
return '/?' + escape(urlencode({key: value for key, value in params.items() if value != ''}))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def options(labels, selected):
|
|
44
|
+
return ''.join(f'<option value="{key}"{" selected" if key == selected else ""}>{label}</option>'
|
|
45
|
+
for key, label in labels.items())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def local_deadline(notice, warsaw):
|
|
49
|
+
deadline = notice.deadline
|
|
50
|
+
if deadline is None:
|
|
51
|
+
return None
|
|
52
|
+
return deadline.astimezone(warsaw) if deadline.tzinfo else deadline.replace(tzinfo=warsaw)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def deadline_date_only(notice):
|
|
56
|
+
if notice.source != 'TED':
|
|
57
|
+
return False
|
|
58
|
+
dates = notice.raw.get('deadline-receipt-tender-date-lot') or []
|
|
59
|
+
first = min((value for value in dates if value), default='')
|
|
60
|
+
return bool(first and len(first) <= 16 and 'T' not in first)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def tab(label, href, active):
|
|
64
|
+
current = ' aria-current="page"' if active else ''
|
|
65
|
+
return f'<a href="{href}"{current}>{escape(label)}</a>'
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def notice_row(notice, deadline, date_only):
|
|
69
|
+
title = escape(notice.title or 'Ogłoszenie bez tytułu')
|
|
70
|
+
buyer = escape(notice.buyer_name or 'Nie podano zamawiającego')
|
|
71
|
+
published = escape(str(notice.published or ''))
|
|
72
|
+
due = ''
|
|
73
|
+
if deadline:
|
|
74
|
+
date_text = deadline.strftime('%d.%m.%Y' if date_only else '%d.%m.%Y, %H:%M')
|
|
75
|
+
due = f'<span>Termin: {date_text}</span>'
|
|
76
|
+
link = ''
|
|
77
|
+
for url in (notice.url, notice.platform_url):
|
|
78
|
+
if not url:
|
|
79
|
+
continue
|
|
80
|
+
try:
|
|
81
|
+
parts = urlsplit(url)
|
|
82
|
+
except ValueError:
|
|
83
|
+
continue
|
|
84
|
+
if parts.scheme in ('http', 'https') and parts.hostname:
|
|
85
|
+
link = (f'<a class="open" href="{escape(url)}" target="_blank" rel="noopener noreferrer"'
|
|
86
|
+
f' aria-label="Otwórz ogłoszenie: {title}">↗</a>')
|
|
87
|
+
break
|
|
88
|
+
return f'''<li class="notice">
|
|
89
|
+
<div>
|
|
90
|
+
<div class="metadata"><span class="badge">{escape(notice.source)}</span><time>{published}</time>{due}</div>
|
|
91
|
+
<h2>{title}</h2>
|
|
92
|
+
<p>{buyer}</p>
|
|
93
|
+
</div>
|
|
94
|
+
{link}
|
|
95
|
+
</li>'''
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render(template, state, profiles, params):
|
|
99
|
+
active, query, source = (params[key] for key in ('profile', 'q', 'source'))
|
|
100
|
+
cpv, deadline_filter, order = (params[key] for key in ('cpv', 'deadline', 'sort'))
|
|
101
|
+
start = date.fromisoformat(params['from']) if params['from'] else None
|
|
102
|
+
end = date.fromisoformat(params['to']) if params['to'] else None
|
|
103
|
+
page = int(params['page'])
|
|
104
|
+
profile = profiles[active]
|
|
105
|
+
terms = fold(query).split()
|
|
106
|
+
warsaw = ZoneInfo('Europe/Warsaw')
|
|
107
|
+
now = datetime.now(timezone.utc)
|
|
108
|
+
found_sources, matches = set(), []
|
|
109
|
+
offset = (page - 1) * PAGE_SIZE
|
|
110
|
+
for notice in filtered(state, profile):
|
|
111
|
+
found_sources.add(notice.source)
|
|
112
|
+
if source and notice.source != source:
|
|
113
|
+
continue
|
|
114
|
+
if start and (not notice.published or notice.published < start):
|
|
115
|
+
continue
|
|
116
|
+
if end and (not notice.published or notice.published > end):
|
|
117
|
+
continue
|
|
118
|
+
if cpv and not any(code.startswith(cpv) for code in notice.cpv):
|
|
119
|
+
continue
|
|
120
|
+
if terms:
|
|
121
|
+
haystack = fold(f'{notice.title or ""} {notice.buyer_name or ""}')
|
|
122
|
+
if not all(term in haystack for term in terms):
|
|
123
|
+
continue
|
|
124
|
+
deadline = local_deadline(notice, warsaw)
|
|
125
|
+
date_only = deadline_date_only(notice)
|
|
126
|
+
if deadline and date_only:
|
|
127
|
+
# Date-only TED fields were stored at midnight with the source offset.
|
|
128
|
+
deadline = notice.deadline.replace(tzinfo=warsaw)
|
|
129
|
+
deadline_utc = deadline.astimezone(timezone.utc) if deadline else None
|
|
130
|
+
if deadline is None:
|
|
131
|
+
status = 'missing'
|
|
132
|
+
elif date_only and deadline.date() == now.astimezone(warsaw).date():
|
|
133
|
+
status = 'today'
|
|
134
|
+
else:
|
|
135
|
+
status = 'upcoming' if deadline_utc >= now else 'passed'
|
|
136
|
+
if deadline_filter and status != deadline_filter:
|
|
137
|
+
continue
|
|
138
|
+
matches.append((replace(notice, raw={}, lots=()), deadline, deadline_utc, date_only))
|
|
139
|
+
|
|
140
|
+
if order == 'oldest':
|
|
141
|
+
matches.sort(key=lambda item: (item[0].published is None, item[0].published))
|
|
142
|
+
elif order == 'deadline':
|
|
143
|
+
matches.sort(key=lambda item: (item[2] is None, item[2]))
|
|
144
|
+
elif order in ('title', 'buyer'):
|
|
145
|
+
field = 'title' if order == 'title' else 'buyer_name'
|
|
146
|
+
matches.sort(key=lambda item: (not getattr(item[0], field), fold(getattr(item[0], field) or '')))
|
|
147
|
+
total = len(matches)
|
|
148
|
+
visible = matches[offset:offset + PAGE_SIZE]
|
|
149
|
+
|
|
150
|
+
def link(**changes):
|
|
151
|
+
return page_link({**params, 'page': 1, **changes})
|
|
152
|
+
|
|
153
|
+
profile_tabs = ''.join(tab(PROFILE_LABELS.get(name, name), link(profile=name, source=''), name == active)
|
|
154
|
+
for name in profiles)
|
|
155
|
+
source_tabs = ''.join(tab(label, link(source=name), source == name)
|
|
156
|
+
for name, label in [('', 'Wszystkie')] +
|
|
157
|
+
[(name, name) for name in sorted(found_sources | ({source} if source else set()))])
|
|
158
|
+
count = f'{offset + 1}–{offset + len(visible)} z {total} ogłoszeń' if visible else f'{total} ogłoszeń'
|
|
159
|
+
pagination = []
|
|
160
|
+
if page > 1:
|
|
161
|
+
pagination.append(tab('← Poprzednia', link(page=page - 1), False))
|
|
162
|
+
if offset + PAGE_SIZE < total:
|
|
163
|
+
pagination.append(tab('Następna →', link(page=page + 1), False))
|
|
164
|
+
rows = ''.join(notice_row(notice, deadline, date_only) for notice, deadline, _, date_only in visible)
|
|
165
|
+
return template.substitute(
|
|
166
|
+
profile_tabs=profile_tabs, source_tabs=source_tabs, profile=escape(active),
|
|
167
|
+
source=escape(source), query=escape(query), count=count,
|
|
168
|
+
published_from=escape(params['from']), published_to=escape(params['to']), cpv=escape(cpv),
|
|
169
|
+
deadline_options=options(DEADLINE_LABELS, deadline_filter), sort_options=options(SORT_LABELS, order),
|
|
170
|
+
reset_link=page_link({'profile': active}),
|
|
171
|
+
criteria=''.join(f'<span>CPV {escape(code)}</span>' for code in profile.cpv)
|
|
172
|
+
+ ''.join(f'<span>bez CPV {escape(code)}</span>' for code in profile.exclude_cpv),
|
|
173
|
+
rows=f'<ul class="notices">{rows}</ul>' if rows else
|
|
174
|
+
'<p class="empty">Brak wyników na tej stronie. Zmień filtry lub wybierz inny profil.</p>',
|
|
175
|
+
pagination=''.join(pagination),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def serve(path, profiles, initial, port=8765):
|
|
180
|
+
if not 0 <= port <= 65535:
|
|
181
|
+
raise ValueError('Port must be between 0 and 65535')
|
|
182
|
+
state = State(path, read_only=True)
|
|
183
|
+
try:
|
|
184
|
+
with state.connection() as connection:
|
|
185
|
+
connection.execute('select 1 from notices limit 1').fetchone()
|
|
186
|
+
except sqlite3.Error as exc:
|
|
187
|
+
raise ValueError(f'Nie można otworzyć bazy ogłoszeń: {state.path}') from exc
|
|
188
|
+
|
|
189
|
+
assets = files('tenderkit').joinpath('web')
|
|
190
|
+
template = Template(assets.joinpath('index.html').read_text(encoding='utf-8'))
|
|
191
|
+
css = assets.joinpath('style.css').read_bytes()
|
|
192
|
+
|
|
193
|
+
class Handler(BaseHTTPRequestHandler):
|
|
194
|
+
def do_GET(self):
|
|
195
|
+
port = self.server.server_port
|
|
196
|
+
if self.headers.get('Host') not in (f'127.0.0.1:{port}', f'localhost:{port}'):
|
|
197
|
+
return self.send_error(403)
|
|
198
|
+
path, _, params = self.path.partition('?')
|
|
199
|
+
if path == '/style.css':
|
|
200
|
+
return self.respond(css, 'text/css')
|
|
201
|
+
if path != '/':
|
|
202
|
+
return self.send_error(404)
|
|
203
|
+
try:
|
|
204
|
+
values = dict(parse_qsl(params, max_num_fields=12))
|
|
205
|
+
filters = dict(profile=initial, q='', source='', cpv='', deadline='', sort='newest', page='1')
|
|
206
|
+
filters.update({'from': '', 'to': ''})
|
|
207
|
+
filters = {key: values.get(key, default).strip() for key, default in filters.items()}
|
|
208
|
+
for key in ('from', 'to'):
|
|
209
|
+
if filters[key]:
|
|
210
|
+
filters[key] = date.fromisoformat(filters[key]).isoformat()
|
|
211
|
+
if (filters['profile'] not in profiles or len(filters['q']) > 200 or len(filters['source']) > 100
|
|
212
|
+
or not re.fullmatch(r'[0-9]{0,8}', filters['cpv']) or int(filters['page']) < 1
|
|
213
|
+
or filters['deadline'] not in DEADLINE_LABELS or filters['sort'] not in SORT_LABELS
|
|
214
|
+
or filters['to'] and filters['from'] > filters['to']):
|
|
215
|
+
raise ValueError
|
|
216
|
+
except ValueError:
|
|
217
|
+
return self.send_error(400, 'Invalid filter')
|
|
218
|
+
try:
|
|
219
|
+
body = render(template, state, profiles, filters)
|
|
220
|
+
except sqlite3.Error:
|
|
221
|
+
return self.send_error(503, 'Database unavailable')
|
|
222
|
+
self.respond(body.encode('utf-8'), 'text/html')
|
|
223
|
+
|
|
224
|
+
def respond(self, body, content_type):
|
|
225
|
+
self.send_response(200)
|
|
226
|
+
self.send_header('Content-Type', content_type + '; charset=utf-8')
|
|
227
|
+
self.send_header('Content-Length', str(len(body)))
|
|
228
|
+
self.send_header('Cache-Control', 'no-store')
|
|
229
|
+
self.send_header('Content-Security-Policy',
|
|
230
|
+
"default-src 'none'; style-src 'self'; form-action 'self'; frame-ancestors 'none'")
|
|
231
|
+
self.send_header('Referrer-Policy', 'no-referrer')
|
|
232
|
+
self.end_headers()
|
|
233
|
+
self.wfile.write(body)
|
|
234
|
+
|
|
235
|
+
def log_message(self, format, *args):
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
with ThreadingHTTPServer(('127.0.0.1', port), Handler) as server:
|
|
239
|
+
print(f'tenderkit UI: http://127.0.0.1:{server.server_port}/', flush=True)
|
|
240
|
+
try:
|
|
241
|
+
server.serve_forever()
|
|
242
|
+
except KeyboardInterrupt:
|
|
243
|
+
pass
|