The order of XML attributes is now retained.
[philipp/winterrodeln/wrpylib.git] / wrpylib / wrmwcache.py
1 """Contains functions that maintain/update the cache tables."""
2 import mwparserfromhell
3 from sqlalchemy import schema, Table
4 from sqlalchemy.engine import Connection
5 from sqlalchemy.sql import select
6 from sqlalchemy.sql.expression import func as sqlfunc
7 from osgeo import ogr
8 from wrpylib import mwdb, wrmwdb, wrmwmarkup, wrvalidators
9
10
11 class UpdateCacheError(RuntimeError):
12     pass
13
14
15 def _get_mw_text(connection: Connection, text: Table, content_address: str) -> str:
16     parts = content_address.split(':')  # e.g. 'tt:15664'
17     if len(parts) != 2 or parts[0] != 'tt':
18         raise ValueError('Content has unexpected format')
19     old_id = int(parts[1])
20     query = select([text], text.c.old_id == old_id)
21     text_row = connection.execute(query).fetchone()
22     return text_row.old_text
23
24
25 def update_wrsledruncache(connection):
26     """Updates the wrsledruncache table from the wiki. If convert errors occur, an UpdateCacheError exception
27     is raised. No other exception type should be raised under normal circumstances.
28     
29     >>> from sqlalchemy.engine import create_engine
30     >>> engine = create_engine('mysql://philipp@localhost:3306/philipp_winterrodeln_wiki?charset=utf8mb4')
31     >>> update_wrsledruncache(engine.connect())
32     """
33     metadata = schema.MetaData()
34     wrsledruncache = wrmwdb.wrsledruncache_table(metadata)
35     page = mwdb.page_table(metadata)
36     categorylinks = mwdb.categorylinks_table(metadata)
37     slots = mwdb.slots_table(metadata)
38     content = mwdb.content_table(metadata)
39     text = mwdb.text_table(metadata)
40
41     class Sledrun:
42         pass
43
44     try:
45         with connection.begin():
46
47             # Query all sled runs
48             q = select(
49                 [page, categorylinks, slots, content],
50                 (page.c.page_latest == slots.c.slot_revision_id) & (slots.c.slot_content_id == content.c.content_id) &
51                 (categorylinks.c.cl_from == page.c.page_id) & (categorylinks.c.cl_to == 'Rodelbahn'))
52             sledrun_pages = connection.execute(q)
53
54             # Delete all existing entries in wrsledruncache
55             # We rely on transactions MySQL InnoDB
56             connection.execute(wrsledruncache.delete())
57
58             # Refill wrsledruncache table
59             for sledrun_page in sledrun_pages:
60                 old_text = _get_mw_text(connection, text, sledrun_page.content_address)
61                 rodelbahnbox = wrvalidators.rodelbahnbox_from_str(old_text)
62                 sledrun = wrmwmarkup.sledrun_from_rodelbahnbox(rodelbahnbox, Sledrun())
63                 sledrun.page_id = sledrun_page.page_id
64                 sledrun.page_title = sledrun_page.page_title
65                 sledrun.name_url = wrvalidators.sledrun_page_title_to_pretty_url(sledrun_page.page_title)
66                 sledrun.under_construction = connection.execute(select(
67                     [sqlfunc.count()],
68                     (categorylinks.c.cl_from == sledrun_page.page_id) &
69                     (categorylinks.c.cl_to == 'In_Arbeit')).alias('x')).fetchone()[0] > 0
70                 connection.execute(wrsledruncache.insert(sledrun.__dict__))
71
72     except ValueError as e:
73         error_msg = f"Error at sled run '{sledrun_page.page_title}': {e}"
74         raise UpdateCacheError(error_msg, sledrun_page.page_title, e)
75
76
77 def update_wrinncache(connection):
78     """Updates the wrinncache table from the wiki. If convert errors occur, an UpdateCacheError exception
79     is raised. No other exception type should be raised under normal circumstances.
80     
81     >>> from sqlalchemy.engine import create_engine
82     >>> engine = create_engine('mysql://philipp@localhost:3306/philipp_winterrodeln_wiki?charset=utf8mb4')
83     >>> update_wrinncache(engine.connect())
84     """
85     metadata = schema.MetaData()
86     wrinncache = wrmwdb.wrinncache_table(metadata)
87     page = mwdb.page_table(metadata)
88     categorylinks = mwdb.categorylinks_table(metadata)
89     slots = mwdb.slots_table(metadata)
90     content = mwdb.content_table(metadata)
91     text = mwdb.text_table(metadata)
92
93     class Inn:
94         pass
95
96     try:
97         with connection.begin():
98
99             # Query all inns
100             q = select(
101                 [page, categorylinks, slots, content],
102                 (page.c.page_latest == slots.c.slot_revision_id) & (slots.c.slot_content_id == content.c.content_id) &
103                 (categorylinks.c.cl_from == page.c.page_id) & (categorylinks.c.cl_to == 'Gasthaus'))
104             inn_pages = connection.execute(q)
105
106             # Delete all existing entries in wrinncache
107             # We rely on transactions MySQL InnoDB
108             connection.execute(wrinncache.delete())
109
110             # Refill wrinncache table
111             for inn_page in inn_pages:
112                 old_text = _get_mw_text(connection, text, inn_page.content_address)
113                 gasthausbox = wrvalidators.gasthausbox_from_str(old_text)
114                 inn = wrmwmarkup.inn_from_gasthausbox(gasthausbox, Inn())
115                 inn.page_id = inn_page.page_id
116                 inn.page_title = inn_page.page_title
117                 inn.under_construction = connection.execute(select(
118                     [sqlfunc.count()],
119                     (categorylinks.c.cl_from == inn_page.page_id) &
120                     (categorylinks.c.cl_to == 'In_Arbeit')).alias('x')) \
121                     .fetchone()[0] > 0  # it would be better to do this in the query above
122                 connection.execute(wrinncache.insert(inn.__dict__))
123
124     except ValueError as e:
125         error_msg = f"Error as inn '{inn_page.page_title}': {e}"
126         raise UpdateCacheError(error_msg, inn_page.page_title, e)
127
128
129 def update_wrreportcache(connection, page_id=None):
130     """Updates the wrreportcache table.
131     :param connection: sqlalchemy connection
132     :param page_id: Updates only the reportcache table for the sledrun described on the Winterrodeln wiki page
133         with the specified page_id. Use None for this parameter to update the whole table.
134
135     >>> from sqlalchemy.engine import create_engine
136     >>> engine = create_engine('mysql://philipp@localhost:3306/philipp_winterrodeln_wiki?charset=utf8mb4')
137     >>> update_wrreportcache(engine.connect())
138     """
139     metadata = schema.MetaData()
140     wrreportcache = wrmwdb.wrreportcache_table(metadata)
141     with connection.begin():
142         # Delete the datasets we are going to update
143         sql_del = wrreportcache.delete()
144         if page_id is not None:
145             sql_del = sql_del.where(wrreportcache.c.page_id == page_id)
146         connection.execute(sql_del)
147
148         def insert_row(connection_, row_list_):
149             if len(row_list_) == 0:
150                 return
151             # Insert the report
152             row_ = dict(row_list_[0])
153             connection_.execute(wrreportcache.insert(values=row_))
154
155         # Select the rows to update
156         sql = 'select page_id, page_title, wrreport.id as report_id, date_report, `condition`, description, ' \
157               'author_name, ' \
158               'if(author_userid is null, null, author_username) as author_username from wrreport ' \
159               'where {0}`condition` is not null and date_invalid > now() and delete_date is null ' \
160               'order by page_id, date_report desc, date_entry desc' \
161               .format('' if page_id is None else f'page_id={page_id} and ')
162         cursor = connection.execute(sql)
163         page_id = None
164         row_list = []
165         for row in cursor:
166             if row.page_id != page_id:
167                 insert_row(connection, row_list)
168                 page_id = row.page_id
169                 row_list = []
170             row_list.append(row)
171         insert_row(connection, row_list)
172
173
174 def update_wrmapcache(connection):
175     """Updates the wrmappointcache and wrmappathcache tables from the wiki. If convert errors occur,
176     an UpdateCacheError exception is raised. No other exception type should be raised under normal circumstances.
177     
178     >>> from sqlalchemy.engine import create_engine
179     >>> engine = create_engine('mysql://philipp@localhost:3306/philipp_winterrodeln_wiki?charset=utf8mb4')
180     >>> # or:
181     >>> # engine = create_engine('mysql://philipp@localhost:3306/philipp_winterrodeln_wiki?charset=utf8mb4&passwd=XXX')
182     >>> update_wrmapcache(engine.connect())
183     """
184     metadata = schema.MetaData()
185     page = mwdb.page_table(metadata)
186     categorylinks = mwdb.categorylinks_table(metadata)
187     slots = mwdb.slots_table(metadata)
188     content = mwdb.content_table(metadata)
189     text = mwdb.text_table(metadata)
190
191     try:
192         with connection.begin():
193
194             # Query all sledruns
195             q = select(
196                 [page, categorylinks, slots, content],
197                 (page.c.page_latest == slots.c.slot_revision_id) & (slots.c.slot_content_id == content.c.content_id) &
198                 (categorylinks.c.cl_from == page.c.page_id) & (categorylinks.c.cl_to == 'Rodelbahn'))
199             sledrun_pages = connection.execute(q)
200
201             # Delete all existing entries in wrmappointcache
202             # We rely on transactions MySQL InnoDB
203             connection.execute('delete from wrmappointcache')
204             connection.execute('delete from wrmappathcache')
205
206             # Refill wrmappointcache and wrmappathcache tables
207             for sledrun_page in sledrun_pages:
208                 old_text = _get_mw_text(connection, text, sledrun_page.content_address)
209                 wikicode = mwparserfromhell.parse(old_text)
210                 wrmap_list = wikicode.filter_tags(recursive=False, matches=lambda tag: tag.tag == 'wrmap')
211                 if len(wrmap_list) == 0:
212                     continue  # not wrmap in page
213                 if len(wrmap_list) > 1:
214                     raise UpdateCacheError(
215                         f'{len(wrmap_list)} <wrmap ...> entries found in article "{sledrun_page.page_title}"')
216                 wrmap = wrmap_list[0]
217                 geojson = wrmwmarkup.parse_wrmap(str(wrmap))
218
219                 for feature in geojson['features']:
220                     properties = feature['properties']
221                     coordinates = feature['geometry']['coordinates']
222
223                     # Points
224                     if properties['type'] in wrmwmarkup.WRMAP_POINT_TYPES:
225                         lon, lat = coordinates
226                         label = properties.get('name')
227                         point_types = {
228                             'gasthaus': 'hut',
229                             'haltestelle': 'busstop',
230                             'parkplatz': 'carpark',
231                             'achtung': 'warning',
232                             'foto': 'photo',
233                             'verleih': 'rental',
234                             'punkt': 'point'
235                         }
236                         point_type = point_types[properties['type']]
237                         sql = 'insert into wrmappointcache (page_id, type, point, label) values (%s, %s, POINT(%s, %s), %s)'
238                         connection.execute(sql, (sledrun_page.page_id, point_type, lon, lat, label))
239
240                     # Paths
241                     elif properties['type'] in wrmwmarkup.WRMAP_LINE_TYPES:
242                         path_types = {
243                             'rodelbahn': 'sledrun',
244                             'gehweg': 'walkup',
245                             'alternative': 'alternative',
246                             'lift': 'lift',
247                             'anfahrt': 'recommendedcarroute',
248                             'linie': 'line'}
249                         path_type = path_types[properties['type']]
250                         path = ", ".join([f"{lon} {lat}" for lon, lat in coordinates])
251                         path = f'LineString({path})'
252                         if path_type == 'recommendedcarroute':
253                             continue
254                         sql = 'insert into wrmappathcache (path, page_id, type) values (GeomFromText(%s), %s, %s)'
255                         connection.execute(sql, (path, sledrun_page.page_id, path_type))
256
257                     else:
258                         raise RuntimeError(f'Unknown feature type {properties["type"]}')
259     except RuntimeError as e:
260         error_msg = f"Error at sledrun '{sledrun_page.page_title}': {e}"
261         raise UpdateCacheError(error_msg, sledrun_page.page_title, e)
262
263
264 def update_wrregioncache(connection):
265     """Updates the wrregioncache table from the wiki.
266     It relays on the table wrsledruncache to be up-to-date.
267     No exceptions should be raised under normal circumstances.
268     
269     >>> from sqlalchemy.engine import create_engine
270     >>> engine = create_engine('mysql://philipp@localhost:3306/philipp_winterrodeln_wiki?charset=utf8mb4')
271     >>> # or:
272     >>> # engine = create_engine('mysql://philipp@localhost:3306/philipp_winterrodeln_wiki?charset=utf8mb4&passwd=XXX')
273     >>> update_wrregioncache(engine.connect())
274     """
275     metadata = schema.MetaData()
276     wrregion = wrmwdb.wrregion_table(metadata)
277     wrsledruncache = wrmwdb.wrsledruncache_table(metadata)
278     wrregioncache = wrmwdb.wrregioncache_table(metadata)
279
280     with connection.begin():
281
282         # Delete all existing entries in wrregioncache
283         # We rely on transactions MySQL InnoDB
284         connection.execute(wrregioncache.delete())
285
286         # Query all combinations of sledruns and regions
287         sel = select(
288             [
289                 wrregion.c.id.label('region_id'),
290                 sqlfunc.AsWKB(wrregion.c.border).label('border'),
291                 wrsledruncache.c.page_id,
292                 wrsledruncache.c.position_longitude,
293                 wrsledruncache.c.position_latitude
294             ],
295             sqlfunc.contains(
296                 wrregion.c.border,
297                 sqlfunc.point(wrsledruncache.c.position_longitude, wrsledruncache.c.position_latitude)
298             )
299         )
300         ins = wrregioncache.insert()
301
302         # Refill wrregioncache
303         point = ogr.Geometry(ogr.wkbPoint)
304         result = connection.execute(sel)
305         for row in result:
306             point.SetPoint(0, row.position_longitude, row.position_latitude)
307             if point.Within(ogr.CreateGeometryFromWkb(row.border)):
308                 connection.execute(ins.values(region_id=row.region_id, page_id=row.page_id))