SPB Git

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

Phase 2 — noyau : archivage des passés, statut, end_time, quarantaine, séances, villes MAMH

- Règle d ARCHIVAGE temporel : un événement terminé passe active=0 à chaque
  cycle (jamais supprimé) ; un passé archivé n est plus réactivé par un sync
  « unchanged » (backfill immédiat : 3 745 passés actifs → 0).
- Colonnes additives events.end_time (HH:MM), events.status
  (cancelled/postponed/soldout, NULL = prévu) et events.quarantine
  (raison, NULL = publiable) + migration auto à la connexion.
- dedup_key inclut désormais l HEURE de début : même titre/ville/jour à des
  heures différentes = séances distinctes (661 groupes → 259 ; les 11 séances
  d un même parcours redeviennent visibles). Deux fiches sans heure fusionnent
  toujours ; une fiche avec heure ne fusionne qu à heure égale (règle consignée
  dans schema.py).
- Quarantaine qualité : date passée aberrante (< 2000), début à plus de 2 ans,
  ville hors Québec — non publiée, comptée (/api/stats), réintégrable à chaque
  sync dès que la donnée redevient saine. L heure 00:00 est affichée « heure
  non confirmée » ; l image 404 n est pas vérifiée (coût réseau), le frontend
  retire les images cassées.
- Alerte « source qui meurt en silence » : événements FUTURS par source à
  chaque cycle (data/future_counts.json) — alerte si 0 futur ou chute > 80 %
  (le cas Laval : 41 runs verts, max(date)=2025-12-09).
- Géocodage par ADRESSE CIVIQUE prioritaire (Nominatim, même cache, 1 req/s) :
  Brossard 0,7 % → 94 % de GPS, Longueuil 0 % → 75 %.
- Villes normalisées au Répertoire MAMH (data/villes_canon.json, généré de
  MUN.csv + ARR.csv) : « Montreal » 375, « Quebec » 166, 75 graphies, 804
  événements backfillés — facettes ville unifiées.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 19, 2026) parent 49bc70b Browse files

9 changed files +406 −45

modified .gitignore +1 −0
@@ -16,3 +16,4 @@ data/sortika.db-shm
16 data/sortika.db-wal 16 data/sortika.db-wal
17 data/sortika.db 17 data/sortika.db
18 data/*.bak-* 18 data/*.bak-*
19+data/future_counts.json
added data/villes_canon.json +1 −0
@@ -0,0 +1 @@
1+{"_comment": "Sorti-Ka — noms canoniques (accentués) des municipalités et arrondissements du Québec, générés depuis le Répertoire des municipalités (MAMH, MUN.csv + ARR.csv, données ouvertes) le 2026-08-19. Clé = nom normalisé (minuscules, sans accents/tirets). Sert à normaliser l'affichage des villes (« Montreal » → « Montréal »).", "villes": {"abercorn": "Abercorn", "acton vale": "Acton Vale", "adstock": "Adstock", "aguanish": "Aguanish", "ahuntsic cartierville": "Ahuntsic-Cartierville", "akulivik": "Akulivik", "akwesasne": "Akwesasne", "albanel": "Albanel", "albertville": "Albertville", "alleyn et cawood": "Alleyn-et-Cawood", "alma": "Alma", "amherst": "Amherst", "amos": "Amos", "amqui": "Amqui", "ange gardien": "Ange-Gardien", "anjou": "Anjou", "armagh": "Armagh", "arundel": "Arundel", "ascot corner": "Ascot Corner", "aston jonction": "Aston-Jonction", "auclair": "Auclair", "audet": "Audet", "aumond": "Aumond", "aupaluk": "Aupaluk", "austin": "Austin", "authier": "Authier", "authier nord": "Authier-Nord", "ayer s cliff": "Ayer's Cliff", "baie atibenne": "Baie-Atibenne", "baie comeau": "Baie-Comeau", "baie d hudson": "Baie-d'Hudson", "baie d urfe": "Baie-D'Urfé", "baie de la bouteille": "Baie-de-la-Bouteille", "baie des chaloupes": "Baie-des-Chaloupes", "baie des sables": "Baie-des-Sables", "baie du febvre": "Baie-du-Febvre", "baie johan beetz": "Baie-Johan-Beetz", "baie obaoca": "Baie-Obaoca", "baie saint paul": "Baie-Saint-Paul", "baie sainte catherine": "Baie-Sainte-Catherine", "baie trinite": "Baie-Trinité", "barkmere": "Barkmere", "barnston ouest": "Barnston-Ouest", "barraute": "Barraute", "batiscan": "Batiscan", "beaconsfield": "Beaconsfield", "bearn": "Béarn", "beauceville": "Beauceville", "beauharnois": "Beauharnois", "beaulac garthby": "Beaulac-Garthby", "beaumont": "Beaumont", "beauport": "Beauport", "beaupre": "Beaupré", "becancour": "Bécancour", "bedford": "Bedford", "begin": "Bégin", "belcourt": "Belcourt", "belle riviere": "Belle-Rivière", "belleterre": "Belleterre", "beloeil": "Beloeil", "berry": "Berry", "berthier sur mer": "Berthier-sur-Mer", "berthierville": "Berthierville", "bethanie": "Béthanie", "biencourt": "Biencourt", "blainville": "Blainville", "blanc sablon": "Blanc-Sablon", "blue sea": "Blue Sea", "boileau": "Boileau", "bois des filion": "Bois-des-Filion", "bois franc": "Bois-Franc", "boisbriand": "Boisbriand", "boischatel": "Boischatel", "bolton est": "Bolton-Est", "bolton ouest": "Bolton-Ouest", "bonaventure": "Bonaventure", "bonne esperance": "Bonne-Espérance", "bonsecours": "Bonsecours", "boucherville": "Boucherville", "bouchette": "Bouchette", "bowman": "Bowman", "brebeuf": "Brébeuf", "brigham": "Brigham", "bristol": "Bristol", "brome": "Brome", "bromont": "Bromont", "brompton rock forest saint elie deauville": "Brompton--Rock Forest--Saint-Élie--Deauville", "brossard": "Brossard", "brownsburg chatham": "Brownsburg-Chatham", "bryson": "Bryson", "bury": "Bury", "cacouna": "Cacouna", "calixa lavallee": "Calixa-Lavallée", "calumet": "Calumet", "campbell s bay": "Campbell's Bay", "candiac": "Candiac", "caniapiscau": "Caniapiscau", "cantley": "Cantley", "cap chat": "Cap-Chat", "cap saint ignace": "Cap-Saint-Ignace", "cap sante": "Cap-Santé", "caplan": "Caplan", "carignan": "Carignan", "carleton sur mer": "Carleton-sur-Mer", "cascades malignes": "Cascades-Malignes", "cascapedia saint jules": "Cascapédia--Saint-Jules", "causapscal": "Causapscal", "cayamant": "Cayamant", "chambly": "Chambly", "chambord": "Chambord", "champlain": "Champlain", "champneuf": "Champneuf", "chandler": "Chandler", "chapais": "Chapais", "charette": "Charette", "charlemagne": "Charlemagne", "charlesbourg": "Charlesbourg", "chartierville": "Chartierville", "chateau richer": "Château-Richer", "chateauguay": "Châteauguay", "chazel": "Chazel", "chelsea": "Chelsea", "cheneville": "Chénéville", "chertsey": "Chertsey", "chesterville": "Chesterville", "chibougamau": "Chibougamau", "chichester": "Chichester", "chicoutimi": "Chicoutimi", "chisasibi": "Chisasibi", "chute aux outardes": "Chute-aux-Outardes", "chute saint philippe": "Chute-Saint-Philippe", "clarenceville": "Clarenceville", "clarendon": "Clarendon", "clermont": "Clermont", "clerval": "Clerval", "cleveland": "Cleveland", "cloridorme": "Cloridorme", "coaticook": "Coaticook", "collines du basque": "Collines-du-Basque", "colombier": "Colombier", "compton": "Compton", "contrecoeur": "Contrecoeur", "cookshire eaton": "Cookshire-Eaton", "cote des neiges notre dame de grace": "Côte-des-Neiges--Notre-Dame-de-Grâce", "cote nord du golfe du saint laurent": "Côte-Nord-du-Golfe-du-Saint-Laurent", "cote saint luc": "Côte-Saint-Luc", "coteau du lac": "Coteau-du-Lac", "coucoucache": "Coucoucache", "coulee des adolphe": "Coulée-des-Adolphe", "courcelles saint evariste": "Courcelles--Saint-Évariste", "cowansville": "Cowansville", "crabtree": "Crabtree", "danville": "Danville", "daveluyville": "Daveluyville", "degelis": "Dégelis", "deleage": "Déléage", "delson": "Delson", "denholm": "Denholm", "depot echouani": "Dépôt-Échouani", "desbiens": "Desbiens", "deschaillons sur saint laurent": "Deschaillons-sur-Saint-Laurent", "deschambault grondines": "Deschambault-Grondines", "desjardins": "Desjardins", "deux montagnes": "Deux-Montagnes", "disraeli": "Disraeli", "dixville": "Dixville", "dolbeau mistassini": "Dolbeau-Mistassini", "dollard des ormeaux": "Dollard-des-Ormeaux", "doncaster": "Doncaster", "donnacona": "Donnacona", "dorval": "Dorval", "dosquet": "Dosquet", "drummondville": "Drummondville", "dudswell": "Dudswell", "duhamel": "Duhamel", "duhamel ouest": "Duhamel-Ouest", "dundee": "Dundee", "dunham": "Dunham", "duparquet": "Duparquet", "dupuy": "Dupuy", "durham sud": "Durham-Sud", "east angus": "East Angus", "east broughton": "East Broughton", "east farnham": "East Farnham", "east hereford": "East Hereford", "eastmain": "Eastmain", "eastman": "Eastman", "eeyou istchee baie james": "Eeyou Istchee Baie-James", "egan sud": "Egan-Sud", "elgin": "Elgin", "entrelacs": "Entrelacs", "escuminac": "Escuminac", "esprit saint": "Esprit-Saint", "essipit": "Essipit", "esterel": "Estérel", "farnham": "Farnham", "fassett": "Fassett", "ferland et boilleau": "Ferland-et-Boilleau", "ferme neuve": "Ferme-Neuve", "fermont": "Fermont", "fleurimont": "Fleurimont", "forestville": "Forestville", "fort coulonge": "Fort-Coulonge", "fortierville": "Fortierville", "fossambault sur le lac": "Fossambault-sur-le-Lac", "frampton": "Frampton", "franklin": "Franklin", "franquelin": "Franquelin", "frelighsburg": "Frelighsburg", "frontenac": "Frontenac", "fugereville": "Fugèreville", "gallichan": "Gallichan", "gaspe": "Gaspé", "gatineau": "Gatineau", "gesgapegiag": "Gesgapegiag", "girardville": "Girardville", "godbout": "Godbout", "godmanchester": "Godmanchester", "gore": "Gore", "gracefield": "Gracefield", "granby": "Granby", "grand metis": "Grand-Métis", "grand remous": "Grand-Remous", "grand saint esprit": "Grand-Saint-Esprit", "grande riviere": "Grande-Rivière", "grande vallee": "Grande-Vallée", "grandes piles": "Grandes-Piles", "greenfield park": "Greenfield Park", "grenville": "Grenville", "grenville sur la rouge": "Grenville-sur-la-Rouge", "gros mecatina": "Gros-Mécatina", "grosse ile": "Grosse-Île", "grosses roches": "Grosses-Roches", "guerin": "Guérin", "ham nord": "Ham-Nord", "ham sud": "Ham-Sud", "hampden": "Hampden", "hampstead": "Hampstead", "harrington": "Harrington", "hatley": "Hatley", "havelock": "Havelock", "havre saint pierre": "Havre-Saint-Pierre", "hebertville": "Hébertville", "hemmingford": "Hemmingford", "henryville": "Henryville", "herouxville": "Hérouxville", "hinchinbrooke": "Hinchinbrooke", "honfleur": "Honfleur", "hope": "Hope", "hope town": "Hope Town", "howick": "Howick", "huberdeau": "Huberdeau", "hudson": "Hudson", "huntingdon": "Huntingdon", "inukjuak": "Inukjuak", "inverness": "Inverness", "irlande": "Irlande", "ivry sur le lac": "Ivry-sur-le-Lac", "ivujivik": "Ivujivik", "joliette": "Joliette", "jonquiere": "Jonquière", "kahnawake": "Kahnawake", "kamouraska": "Kamouraska", "kangiqsualujjuaq": "Kangiqsualujjuaq", "kangiqsujuaq": "Kangiqsujuaq", "kangirsuk": "Kangirsuk", "kataskomiq": "Kataskomiq", "kawawachikamach": "Kawawachikamach", "kazabazua": "Kazabazua", "kebaowek": "Kebaowek", "kiamika": "Kiamika", "kingsbury": "Kingsbury", "kingsey falls": "Kingsey Falls", "kinnear s mills": "Kinnear's Mills", "kipawa": "Kipawa", "kirkland": "Kirkland", "kitigan zibi": "Kitigan Zibi", "kuujjuaq": "Kuujjuaq", "kuujjuarapik": "Kuujjuarapik", "l ancienne lorette": "L'Ancienne-Lorette", "l ange gardien": "L'Ange-Gardien", "l anse saint jean": "L'Anse-Saint-Jean", "l ascension": "L'Ascension", "l ascension de notre seigneur": "L'Ascension-de-Notre-Seigneur", "l ascension de patapedia": "L'Ascension-de-Patapédia", "l assomption": "L'Assomption", "l avenir": "L'Avenir", "l epiphanie": "L'Épiphanie", "l ile bizard sainte genevieve": "L'Île-Bizard--Sainte-Geneviève", "l ile cadieux": "L'Île-Cadieux", "l ile d anticosti": "L'Île-d'Anticosti", "l ile dorval": "L'Île-Dorval", "l ile du grand calumet": "L'Île-du-Grand-Calumet", "l ile perrot": "L'Île-Perrot", "l isle aux allumettes": "L'Isle-aux-Allumettes", "l isle aux coudres": "L'Isle-aux-Coudres", "l isle verte": "L'Isle-Verte", "l islet": "L'Islet", "la baie": "La Baie", "la bostonnais": "La Bostonnais", "la cite limoilou": "La Cité-Limoilou", "la conception": "La Conception", "la corne": "La Corne", "la dore": "La Doré", "la durantaye": "La Durantaye", "la guadeloupe": "La Guadeloupe", "la haute saint charles": "La Haute-Saint-Charles", "la macaza": "La Macaza", "la malbaie": "La Malbaie", "la martre": "La Martre", "la minerve": "La Minerve", "la morandiere rochebaucourt": "La Morandière-Rochebaucourt", "la motte": "La Motte", "la patrie": "La Patrie", "la peche": "La Pêche", "la pocatiere": "La Pocatière", "la prairie": "La Prairie", "la presentation": "La Présentation", "la redemption": "La Rédemption", "la reine": "La Reine", "la romaine": "La Romaine", "la sarre": "La Sarre", "la trinite des monts": "La Trinité-des-Monts", "la tuque": "La Tuque", "la visitation de l ile dupas": "La Visitation-de-l'Île-Dupas", "la visitation de yamaska": "La Visitation-de-Yamaska", "labelle": "Labelle", "labrecque": "Labrecque", "lac a la croix": "Lac-à-la-Croix", "lac achouakan": "Lac-Achouakan", "lac akonapwehikan": "Lac-Akonapwehikan", "lac alfred": "Lac-Alfred", "lac ashuapmushuan": "Lac-Ashuapmushuan", "lac au brochet": "Lac-au-Brochet", "lac au saumon": "Lac-au-Saumon", "lac aux sables": "Lac-aux-Sables", "lac bazinet": "Lac-Bazinet", "lac beauport": "Lac-Beauport", "lac blanc": "Lac-Blanc", "lac boisbouscache": "Lac-Boisbouscache", "lac bouchette": "Lac-Bouchette", "lac boule": "Lac-Boulé", "lac brome": "Lac-Brome", "lac cabasta": "Lac-Cabasta", "lac casault": "Lac-Casault", "lac chicobi": "Lac-Chicobi", "lac croche": "Lac-Croche", "lac de la bidiere": "Lac-De La Bidière", "lac de la maison de pierre": "Lac-de-la-Maison-de-Pierre", "lac de la pomme": "Lac-de-la-Pomme", "lac delage": "Lac-Delage", "lac des aigles": "Lac-des-Aigles", "lac des dix milles": "Lac-des-Dix-Milles", "lac des eaux mortes": "Lac-des-Eaux-Mortes", "lac des ecorces": "Lac-des-Écorces", "lac des plages": "Lac-des-Plages", "lac des seize iles": "Lac-des-Seize-Îles", "lac despinassy": "Lac-Despinassy", "lac devenyns": "Lac-Devenyns", "lac douaire": "Lac-Douaire", "lac drolet": "Lac-Drolet", "lac du cerf": "Lac-du-Cerf", "lac du taureau": "Lac-du-Taureau", "lac duparquet": "Lac-Duparquet", "lac edouard": "Lac-Édouard", "lac ernest": "Lac-Ernest", "lac etchemin": "Lac-Etchemin", "lac frontiere": "Lac-Frontière", "lac granet": "Lac-Granet", "lac huron": "Lac-Huron", "lac jacques cartier": "Lac-Jacques-Cartier", "lac jerome": "Lac-Jérôme", "lac john": "Lac-John", "lac juillet": "Lac-Juillet", "lac lapeyrere": "Lac-Lapeyrère", "lac legendre": "Lac-Legendre", "lac lenotre": "Lac-Lenôtre", "lac marguerite": "Lac-Marguerite", "lac masketsi": "Lac-Masketsi", "lac matapedia": "Lac-Matapédia", "lac matawin": "Lac-Matawin", "lac megantic": "Lac-Mégantic", "lac metei": "Lac-Metei", "lac minaki": "Lac-Minaki", "lac ministuk": "Lac-Ministuk", "lac moncouche": "Lac-Moncouche", "lac moselle": "Lac-Moselle", "lac nilgaut": "Lac-Nilgaut", "lac normand": "Lac-Normand", "lac oscar": "Lac-Oscar", "lac pikauba": "Lac-Pikauba", "lac poulin": "Lac-Poulin", "lac pythonga": "Lac-Pythonga", "lac rapide": "Lac-Rapide", "lac saguay": "Lac-Saguay", "lac saint joseph": "Lac-Saint-Joseph", "lac saint paul": "Lac-Saint-Paul", "lac sainte marie": "Lac-Sainte-Marie", "lac sante": "Lac-Santé", "lac sergent": "Lac-Sergent", "lac simon": "Lac-Simon", "lac superieur": "Lac-Supérieur", "lac tremblant nord": "Lac-Tremblant-Nord", "lac vacher": "Lac-Vacher", "lac wagwabika": "Lac-Wagwabika", "lac walker": "Lac-Walker", "lachine": "Lachine", "lachute": "Lachute", "lacolle": "Lacolle", "laforce": "Laforce", "lalemant": "Lalemant", "lamarche": "Lamarche", "lambton": "Lambton", "landrienne": "Landrienne", "laniel": "Laniel", "lanoraie": "Lanoraie", "lantier": "Lantier", "larouche": "Larouche", "lasalle": "LaSalle", "latulipe et gaboury": "Latulipe-et-Gaboury", "launay": "Launay", "laurier station": "Laurier-Station", "laurierville": "Laurierville", "laval": "Laval", "lavaltrie": "Lavaltrie", "laverlochere angliers": "Laverlochère-Angliers", "lawrenceville": "Lawrenceville", "le plateau mont royal": "Le Plateau-Mont-Royal", "le sud ouest": "Le Sud-Ouest", "le vieux longueuil": "Le Vieux-Longueuil", "lebel sur quevillon": "Lebel-sur-Quévillon", "leclercville": "Leclercville", "lefebvre": "Lefebvre", "lejeune": "Lejeune", "lemieux": "Lemieux", "lennoxville": "Lennoxville", "lery": "Léry", "les bergeronnes": "Les Bergeronnes", "les cedres": "Les Cèdres", "les chutes de la chaudiere est": "Les Chutes-de-la-Chaudière-Est", "les chutes de la chaudiere ouest": "Les Chutes-de-la-Chaudière-Ouest", "les coteaux": "Les Coteaux", "les eboulements": "Les Éboulements", "les escoumins": "Les Escoumins", "les hauteurs": "Les Hauteurs", "les iles de la madeleine": "Les Îles-de-la-Madeleine", "les lacs du temiscamingue": "Les Lacs-du-Témiscamingue", "les mechins": "Les Méchins", "les nations": "Les Nations", "les rivieres": "Les Rivières", "levis": "Lévis", "lingwick": "Lingwick", "linton": "Linton", "listuguj": "Listuguj", "litchfield": "Litchfield", "lochaber": "Lochaber", "lochaber partie ouest": "Lochaber-Partie-Ouest", "longue pointe de mingan": "Longue-Pointe-de-Mingan", "longue rive": "Longue-Rive", "longueuil": "Longueuil", "lorraine": "Lorraine", "lorrainville": "Lorrainville", "lotbiniere": "Lotbinière", "louiseville": "Louiseville", "low": "Low", "lyster": "Lyster", "macamic": "Macamic", "macnider": "MacNider", "maddington falls": "Maddington Falls", "magog": "Magog", "malartic": "Malartic", "maliotenam": "Maliotenam", "manawan": "Manawan", "mandeville": "Mandeville", "maniwaki": "Maniwaki", "manseau": "Manseau", "mansfield et pontefract": "Mansfield-et-Pontefract", "maria": "Maria", "maricourt": "Maricourt", "marieville": "Marieville", "marsoui": "Marsoui", "marston": "Marston", "martinville": "Martinville", "mascouche": "Mascouche", "mashteuiatsh": "Mashteuiatsh", "maskinonge": "Maskinongé", "massueville": "Massueville", "matagami": "Matagami", "matane": "Matane", "matapedia": "Matapédia", "matchi manitou": "Matchi-Manitou", "matimekosh": "Matimekosh", "mayo": "Mayo", "mcmasterville": "McMasterville", "melbourne": "Melbourne", "mercier": "Mercier", "mercier hochelaga maisonneuve": "Mercier--Hochelaga-Maisonneuve", "messines": "Messines", "metabetchouan lac a la croix": "Métabetchouan--Lac-à-la-Croix", "metis sur mer": "Métis-sur-Mer", "milan": "Milan", "mille isles": "Mille-Isles", "mingan": "Mingan", "mirabel": "Mirabel", "mistissini": "Mistissini", "moffet": "Moffet", "mont albert": "Mont-Albert", "mont alexandre": "Mont-Alexandre", "mont apica": "Mont-Apica", "mont blanc": "Mont-Blanc", "mont carmel": "Mont-Carmel", "mont elie": "Mont-Élie", "mont joli": "Mont-Joli", "mont laurier": "Mont-Laurier", "mont royal": "Mont-Royal", "mont saint gregoire": "Mont-Saint-Grégoire", "mont saint hilaire": "Mont-Saint-Hilaire", "mont saint michel": "Mont-Saint-Michel", "mont saint pierre": "Mont-Saint-Pierre", "mont tremblant": "Mont-Tremblant", "mont valin": "Mont-Valin", "montcalm": "Montcalm", "montcerf lytton": "Montcerf-Lytton", "montebello": "Montebello", "montmagny": "Montmagny", "montpellier": "Montpellier", "montreal": "Montréal", "montreal est": "Montréal-Est", "montreal nord": "Montréal-Nord", "montreal ouest": "Montréal-Ouest", "morin heights": "Morin-Heights", "mulgrave et derry": "Mulgrave-et-Derry", "murdochville": "Murdochville", "namur": "Namur", "nantes": "Nantes", "napierville": "Napierville", "natashquan": "Natashquan", "nedelec": "Nédélec", "nemaska": "Nemaska", "neuville": "Neuville", "new carlisle": "New Carlisle", "new richmond": "New Richmond", "newport": "Newport", "nicolet": "Nicolet", "nominingue": "Nominingue", "normandin": "Normandin", "normetal": "Normétal", "north hatley": "North Hatley", "notre dame auxiliatrice de buckland": "Notre-Dame-Auxiliatrice-de-Buckland", "notre dame de bonsecours": "Notre-Dame-de-Bonsecours", "notre dame de ham": "Notre-Dame-de-Ham", "notre dame de l ile perrot": "Notre-Dame-de-l'Île-Perrot", "notre dame de la merci": "Notre-Dame-de-la-Merci", "notre dame de la paix": "Notre-Dame-de-la-Paix", "notre dame de la salette": "Notre-Dame-de-la-Salette", "notre dame de lorette": "Notre-Dame-de-Lorette", "notre dame de lourdes": "Notre-Dame-de-Lourdes", "notre dame de montauban": "Notre-Dame-de-Montauban", "notre dame de pontmain": "Notre-Dame-de-Pontmain", "notre dame de stanbridge": "Notre-Dame-de-Stanbridge", "notre dame des anges": "Notre-Dame-des-Anges", "notre dame des bois": "Notre-Dame-des-Bois", "notre dame des monts": "Notre-Dame-des-Monts", "notre dame des neiges": "Notre-Dame-des-Neiges", "notre dame des pins": "Notre-Dame-des-Pins", "notre dame des prairies": "Notre-Dame-des-Prairies", "notre dame des sept douleurs": "Notre-Dame-des-Sept-Douleurs", "notre dame du bon conseil": "Notre-Dame-du-Bon-Conseil", "notre dame du laus": "Notre-Dame-du-Laus", "notre dame du mont carmel": "Notre-Dame-du-Mont-Carmel", "notre dame du nord": "Notre-Dame-du-Nord", "notre dame du portage": "Notre-Dame-du-Portage", "notre dame du rosaire": "Notre-Dame-du-Rosaire", "notre dame du sacre coeur d issoudun": "Notre-Dame-du-Sacré-Coeur-d'Issoudun", "nouvelle": "Nouvelle", "noyan": "Noyan", "obedjiwan": "Obedjiwan", "odanak": "Odanak", "ogden": "Ogden", "oka": "Oka", "orford": "Orford", "ormstown": "Ormstown", "otter lake": "Otter Lake", "otterburn park": "Otterburn Park", "ouje bougoumou": "Oujé-Bougoumou", "outremont": "Outremont", "packington": "Packington", "padoue": "Padoue", "palmarolle": "Palmarolle", "papineauville": "Papineauville", "parisville": "Parisville", "paspebiac": "Paspébiac", "passes dangereuses": "Passes-Dangereuses", "perce": "Percé", "peribonka": "Péribonka", "pessamit": "Pessamit", "petit lac sainte anne": "Petit-Lac-Sainte-Anne", "petit mecatina": "Petit-Mécatina", "petit saguenay": "Petit-Saguenay", "petite riviere saint francois": "Petite-Rivière-Saint-François", "petite vallee": "Petite-Vallée", "picard": "Picard", "piedmont": "Piedmont", "pierrefonds roxboro": "Pierrefonds-Roxboro", "pierreville": "Pierreville", "pike river": "Pike River", "pikogan": "Pikogan", "pincourt": "Pincourt", "piopolis": "Piopolis", "plaisance": "Plaisance", "plessisville": "Plessisville", "pohenegamook": "Pohénégamook", "pointe a la croix": "Pointe-à-la-Croix", "pointe aux outardes": "Pointe-aux-Outardes", "pointe calumet": "Pointe-Calumet", "pointe claire": "Pointe-Claire", "pointe des cascades": "Pointe-des-Cascades", "pointe fortune": "Pointe-Fortune", "pointe lebel": "Pointe-Lebel", "pont rouge": "Pont-Rouge", "pontiac": "Pontiac", "port cartier": "Port-Cartier", "port daniel gascons": "Port-Daniel--Gascons", "portage du fort": "Portage-du-Fort", "portneuf": "Portneuf", "portneuf sur mer": "Portneuf-sur-Mer", "potton": "Potton", "poularies": "Poularies", "preissac": "Preissac", "prevost": "Prévost", "price": "Price", "princeville": "Princeville", "puvirnituq": "Puvirnituq", "quaqtaq": "Quaqtaq", "quebec": "Québec", "racine": "Racine", "ragueneau": "Ragueneau", "rapide danseur": "Rapide-Danseur", "rapides des joachims": "Rapides-des-Joachims", "rawdon": "Rawdon", "remigny": "Rémigny", "repentigny": "Repentigny", "reservoir dozois": "Réservoir-Dozois", "richelieu": "Richelieu", "richmond": "Richmond", "rigaud": "Rigaud", "rimouski": "Rimouski", "ripon": "Ripon", "ristigouche sud est": "Ristigouche-Sud-Est", "riviere a claude": "Rivière-à-Claude", "riviere a pierre": "Rivière-à-Pierre", "riviere au tonnerre": "Rivière-au-Tonnerre", "riviere aux outardes": "Rivière-aux-Outardes", "riviere beaudette": "Rivière-Beaudette", "riviere bleue": "Rivière-Bleue", "riviere bonaventure": "Rivière-Bonaventure", "riviere bonjour": "Rivière-Bonjour", "riviere de la savane": "Rivière-de-la-Savane", "riviere des prairies pointe aux trembles": "Rivière-des-Prairies--Pointe-aux-Trembles", "riviere du loup": "Rivière-du-Loup", "riviere eternite": "Rivière-Éternité", "riviere heva": "Rivière-Héva", "riviere koksoak": "Rivière-Koksoak", "riviere mistassini": "Rivière-Mistassini", "riviere mouchalagane": "Rivière-Mouchalagane", "riviere nipissis": "Rivière-Nipissis", "riviere nouvelle": "Rivière-Nouvelle", "riviere ojima": "Rivière-Ojima", "riviere ouelle": "Rivière-Ouelle", "riviere patapedia est": "Rivière-Patapédia-Est", "riviere rouge": "Rivière-Rouge", "riviere saint jean": "Rivière-Saint-Jean", "riviere vaseuse": "Rivière-Vaseuse", "roberval": "Roberval", "roquemaure": "Roquemaure", "rosemere": "Rosemère", "rosemont la petite patrie": "Rosemont--La Petite-Patrie", "rougemont": "Rougemont", "routhierville": "Routhierville", "rouyn noranda": "Rouyn-Noranda", "roxton": "Roxton", "roxton falls": "Roxton Falls", "roxton pond": "Roxton Pond", "ruisseau des mineurs": "Ruisseau-des-Mineurs", "ruisseau ferguson": "Ruisseau-Ferguson", "sacre coeur": "Sacré-Coeur", "sacre coeur de jesus": "Sacré-Coeur-de-Jésus", "sagard": "Sagard", "saguenay": "Saguenay", "saint adalbert": "Saint-Adalbert", "saint adelme": "Saint-Adelme", "saint adelphe": "Saint-Adelphe", "saint adolphe d howard": "Saint-Adolphe-d'Howard", "saint adrien": "Saint-Adrien", "saint adrien d irlande": "Saint-Adrien-d'Irlande", "saint agapit": "Saint-Agapit", "saint aime": "Saint-Aimé", "saint aime des lacs": "Saint-Aimé-des-Lacs", "saint aime du lac des iles": "Saint-Aimé-du-Lac-des-Îles", "saint alban": "Saint-Alban", "saint albert": "Saint-Albert", "saint alexandre": "Saint-Alexandre", "saint alexandre de kamouraska": "Saint-Alexandre-de-Kamouraska", "saint alexandre des lacs": "Saint-Alexandre-des-Lacs", "saint alexis": "Saint-Alexis", "saint alexis de matapedia": "Saint-Alexis-de-Matapédia", "saint alexis des monts": "Saint-Alexis-des-Monts", "saint alfred": "Saint-Alfred", "saint alphonse": "Saint-Alphonse", "saint alphonse de granby": "Saint-Alphonse-de-Granby", "saint alphonse rodriguez": "Saint-Alphonse-Rodriguez", "saint amable": "Saint-Amable", "saint ambroise": "Saint-Ambroise", "saint ambroise de kildare": "Saint-Ambroise-de-Kildare", "saint anaclet de lessard": "Saint-Anaclet-de-Lessard", "saint andre avellin": "Saint-André-Avellin", "saint andre d argenteuil": "Saint-André-d'Argenteuil", "saint andre de kamouraska": "Saint-André-de-Kamouraska", "saint andre de restigouche": "Saint-André-de-Restigouche", "saint andre du lac saint jean": "Saint-André-du-Lac-Saint-Jean", "saint anicet": "Saint-Anicet", "saint anselme": "Saint-Anselme", "saint antoine de l isle aux grues": "Saint-Antoine-de-l'Isle-aux-Grues", "saint antoine de tilly": "Saint-Antoine-de-Tilly", "saint antoine sur richelieu": "Saint-Antoine-sur-Richelieu", "saint antonin": "Saint-Antonin", "saint apollinaire": "Saint-Apollinaire", "saint armand": "Saint-Armand", "saint arsene": "Saint-Arsène", "saint athanase": "Saint-Athanase", "saint aubert": "Saint-Aubert", "saint augustin": "Saint-Augustin", "saint augustin de desmaures": "Saint-Augustin-de-Desmaures", "saint augustin de woburn": "Saint-Augustin-de-Woburn", "saint barnabe": "Saint-Barnabé", "saint barnabe sud": "Saint-Barnabé-Sud", "saint barthelemy": "Saint-Barthélemy", "saint basile": "Saint-Basile", "saint basile le grand": "Saint-Basile-le-Grand", "saint benjamin": "Saint-Benjamin", "saint benoit du lac": "Saint-Benoît-du-Lac", "saint benoit labre": "Saint-Benoît-Labre", "saint bernard": "Saint-Bernard", "saint bernard de lacolle": "Saint-Bernard-de-Lacolle", "saint bernard de michaudville": "Saint-Bernard-de-Michaudville", "saint blaise sur richelieu": "Saint-Blaise-sur-Richelieu", "saint bonaventure": "Saint-Bonaventure", "saint boniface": "Saint-Boniface", "saint bruno de guigues": "Saint-Bruno-de-Guigues", "saint bruno de kamouraska": "Saint-Bruno-de-Kamouraska", "saint bruno de montarville": "Saint-Bruno-de-Montarville", "saint calixte": "Saint-Calixte", "saint camille": "Saint-Camille", "saint camille de lellis": "Saint-Camille-de-Lellis", "saint casimir": "Saint-Casimir", "saint celestin": "Saint-Célestin", "saint cesaire": "Saint-Césaire", "saint charles borromee": "Saint-Charles-Borromée", "saint charles de bellechasse": "Saint-Charles-de-Bellechasse", "saint charles de bourget": "Saint-Charles-de-Bourget", "saint charles garnier": "Saint-Charles-Garnier", "saint charles sur richelieu": "Saint-Charles-sur-Richelieu", "saint christophe d arthabaska": "Saint-Christophe-d'Arthabaska", "saint chrysostome": "Saint-Chrysostome", "saint claude": "Saint-Claude", "saint clement": "Saint-Clément", "saint cleophas": "Saint-Cléophas", "saint cleophas de brandon": "Saint-Cléophas-de-Brandon", "saint clet": "Saint-Clet", "saint colomban": "Saint-Colomban", "saint come": "Saint-Côme", "saint come liniere": "Saint-Côme--Linière", "saint constant": "Saint-Constant", "saint cuthbert": "Saint-Cuthbert", "saint cyprien": "Saint-Cyprien", "saint cyprien de napierville": "Saint-Cyprien-de-Napierville", "saint cyrille de lessard": "Saint-Cyrille-de-Lessard", "saint cyrille de wendover": "Saint-Cyrille-de-Wendover", "saint damase": "Saint-Damase", "saint damase de l islet": "Saint-Damase-de-L'Islet", "saint damien": "Saint-Damien", "saint damien de buckland": "Saint-Damien-de-Buckland", "saint david": "Saint-David", "saint david de falardeau": "Saint-David-de-Falardeau", "saint denis de brompton": "Saint-Denis-de-Brompton", "saint denis de la bouteillerie": "Saint-Denis-De La Bouteillerie", "saint denis sur richelieu": "Saint-Denis-sur-Richelieu", "saint didace": "Saint-Didace", "saint dominique": "Saint-Dominique", "saint dominique du rosaire": "Saint-Dominique-du-Rosaire", "saint donat": "Saint-Donat", "saint edmond de grantham": "Saint-Edmond-de-Grantham", "saint edmond les plaines": "Saint-Edmond-les-Plaines", "saint edouard": "Saint-Édouard", "saint edouard de fabre": "Saint-Édouard-de-Fabre", "saint edouard de lotbiniere": "Saint-Édouard-de-Lotbinière", "saint edouard de maskinonge": "Saint-Édouard-de-Maskinongé", "saint elie de caxton": "Saint-Élie-de-Caxton", "saint eloi": "Saint-Éloi", "saint elphege": "Saint-Elphège", "saint elzear": "Saint-Elzéar", "saint elzear de temiscouata": "Saint-Elzéar-de-Témiscouata", "saint emile de suffolk": "Saint-Émile-de-Suffolk", "saint ephrem de beauce": "Saint-Éphrem-de-Beauce", "saint epiphane": "Saint-Épiphane", "saint esprit": "Saint-Esprit", "saint etienne de beauharnois": "Saint-Étienne-de-Beauharnois", "saint etienne de bolton": "Saint-Étienne-de-Bolton", "saint etienne des gres": "Saint-Étienne-des-Grès", "saint eugene": "Saint-Eugène", "saint eugene d argentenay": "Saint-Eugène-d'Argentenay", "saint eugene de guigues": "Saint-Eugène-de-Guigues", "saint eugene de ladriere": "Saint-Eugène-de-Ladrière", "saint eusebe": "Saint-Eusèbe", "saint eustache": "Saint-Eustache", "saint fabien": "Saint-Fabien", "saint fabien de panet": "Saint-Fabien-de-Panet", "saint felicien": "Saint-Félicien", "saint felix d otis": "Saint-Félix-d'Otis", "saint felix de kingsey": "Saint-Félix-de-Kingsey", "saint felix de valois": "Saint-Félix-de-Valois", "saint ferdinand": "Saint-Ferdinand", "saint ferreol les neiges": "Saint-Ferréol-les-Neiges", "saint flavien": "Saint-Flavien", "saint fortunat": "Saint-Fortunat", "saint francois d assise": "Saint-François-d'Assise", "saint francois de l ile d orleans": "Saint-François-de-l'Île-d'Orléans", "saint francois de la riviere du sud": "Saint-François-de-la-Rivière-du-Sud", "saint francois de sales": "Saint-François-de-Sales", "saint francois du lac": "Saint-François-du-Lac", "saint francois xavier de brompton": "Saint-François-Xavier-de-Brompton", "saint francois xavier de viger": "Saint-François-Xavier-de-Viger", "saint frederic": "Saint-Frédéric", "saint fulgence": "Saint-Fulgence", "saint gabriel": "Saint-Gabriel", "saint gabriel de brandon": "Saint-Gabriel-de-Brandon", "saint gabriel de rimouski": "Saint-Gabriel-de-Rimouski", "saint gabriel de valcartier": "Saint-Gabriel-de-Valcartier", "saint gabriel lalemant": "Saint-Gabriel-Lalemant", "saint gedeon": "Saint-Gédéon", "saint gedeon de beauce": "Saint-Gédéon-de-Beauce", "saint georges": "Saint-Georges", "saint georges de windsor": "Saint-Georges-de-Windsor", "saint gerard majella": "Saint-Gérard-Majella", "saint germain de grantham": "Saint-Germain-de-Grantham", "saint germain de kamouraska": "Saint-Germain-de-Kamouraska", "saint gervais": "Saint-Gervais", "saint gilbert": "Saint-Gilbert", "saint gilles": "Saint-Gilles", "saint godefroi": "Saint-Godefroi", "saint guillaume": "Saint-Guillaume", "saint guillaume nord": "Saint-Guillaume-Nord", "saint henri": "Saint-Henri", "saint henri de taillon": "Saint-Henri-de-Taillon", "saint hermenegilde": "Saint-Herménégilde", "saint hilaire de dorset": "Saint-Hilaire-de-Dorset", "saint hilarion": "Saint-Hilarion", "saint hippolyte": "Saint-Hippolyte", "saint honore": "Saint-Honoré", "saint honore de shenley": "Saint-Honoré-de-Shenley", "saint honore de temiscouata": "Saint-Honoré-de-Témiscouata", "saint hubert": "Saint-Hubert", "saint hubert de riviere du loup": "Saint-Hubert-de-Rivière-du-Loup", "saint hugues": "Saint-Hugues", "saint hyacinthe": "Saint-Hyacinthe", "saint ignace de loyola": "Saint-Ignace-de-Loyola", "saint ignace de stanbridge": "Saint-Ignace-de-Stanbridge", "saint irenee": "Saint-Irénée", "saint isidore": "Saint-Isidore", "saint isidore de clifton": "Saint-Isidore-de-Clifton", "saint jacques": "Saint-Jacques", "saint jacques de leeds": "Saint-Jacques-de-Leeds", "saint jacques le majeur de wolfestown": "Saint-Jacques-le-Majeur-de-Wolfestown", "saint jacques le mineur": "Saint-Jacques-le-Mineur", "saint janvier de joly": "Saint-Janvier-de-Joly", "saint jean baptiste": "Saint-Jean-Baptiste", "saint jean de brebeuf": "Saint-Jean-de-Brébeuf", "saint jean de cherbourg": "Saint-Jean-de-Cherbourg", "saint jean de dieu": "Saint-Jean-de-Dieu", "saint jean de l ile d orleans": "Saint-Jean-de-l'Île-d'Orléans", "saint jean de la lande": "Saint-Jean-de-la-Lande", "saint jean de matha": "Saint-Jean-de-Matha", "saint jean port joli": "Saint-Jean-Port-Joli", "saint jean sur richelieu": "Saint-Jean-sur-Richelieu", "saint jerome": "Saint-Jérôme", "saint joachim": "Saint-Joachim", "saint joachim de shefford": "Saint-Joachim-de-Shefford", "saint joseph de beauce": "Saint-Joseph-de-Beauce", "saint joseph de coleraine": "Saint-Joseph-de-Coleraine", "saint joseph de kamouraska": "Saint-Joseph-de-Kamouraska", "saint joseph de lepage": "Saint-Joseph-de-Lepage", "saint joseph de sorel": "Saint-Joseph-de-Sorel", "saint joseph des erables": "Saint-Joseph-des-Érables", "saint joseph du lac": "Saint-Joseph-du-Lac", "saint jude": "Saint-Jude", "saint jules": "Saint-Jules", "saint julien": "Saint-Julien", "saint just de bretenieres": "Saint-Just-de-Bretenières", "saint juste du lac": "Saint-Juste-du-Lac", "saint justin": "Saint-Justin", "saint lambert": "Saint-Lambert", "saint lambert de lauzon": "Saint-Lambert-de-Lauzon", "saint laurent": "Saint-Laurent", "saint laurent de l ile d orleans": "Saint-Laurent-de-l'Île-d'Orléans", "saint lazare": "Saint-Lazare", "saint lazare de bellechasse": "Saint-Lazare-de-Bellechasse", "saint leandre": "Saint-Léandre", "saint leon de standon": "Saint-Léon-de-Standon", "saint leon le grand": "Saint-Léon-le-Grand", "saint leonard": "Saint-Léonard", "saint leonard d aston": "Saint-Léonard-d'Aston", "saint leonard de portneuf": "Saint-Léonard-de-Portneuf", "saint liboire": "Saint-Liboire", "saint liguori": "Saint-Liguori", "saint lin laurentides": "Saint-Lin--Laurentides", "saint louis": "Saint-Louis", "saint louis de blandford": "Saint-Louis-de-Blandford", "saint louis de gonzague": "Saint-Louis-de-Gonzague", "saint louis de gonzague du cap tourmente": "Saint-Louis-de-Gonzague-du-Cap-Tourmente", "saint louis du ha! ha!": "Saint-Louis-du-Ha! Ha!", "saint luc de bellechasse": "Saint-Luc-de-Bellechasse", "saint luc de vincennes": "Saint-Luc-de-Vincennes", "saint lucien": "Saint-Lucien", "saint ludger": "Saint-Ludger", "saint ludger de milot": "Saint-Ludger-de-Milot", "saint magloire": "Saint-Magloire", "saint majorique de grantham": "Saint-Majorique-de-Grantham", "saint malachie": "Saint-Malachie", "saint malo": "Saint-Malo", "saint marc de figuery": "Saint-Marc-de-Figuery", "saint marc des carrieres": "Saint-Marc-des-Carrières", "saint marc du lac long": "Saint-Marc-du-Lac-Long", "saint marc sur richelieu": "Saint-Marc-sur-Richelieu", "saint marcel": "Saint-Marcel", "saint marcel de richelieu": "Saint-Marcel-de-Richelieu", "saint marcellin": "Saint-Marcellin", "saint martin": "Saint-Martin", "saint mathias sur richelieu": "Saint-Mathias-sur-Richelieu", "saint mathieu": "Saint-Mathieu", "saint mathieu d harricana": "Saint-Mathieu-d'Harricana", "saint mathieu de beloeil": "Saint-Mathieu-de-Beloeil", "saint mathieu de rioux": "Saint-Mathieu-de-Rioux", "saint mathieu du parc": "Saint-Mathieu-du-Parc", "saint maurice": "Saint-Maurice", "saint maxime du mont louis": "Saint-Maxime-du-Mont-Louis", "saint medard": "Saint-Médard", "saint michel": "Saint-Michel", "saint michel de bellechasse": "Saint-Michel-de-Bellechasse", "saint michel des saints": "Saint-Michel-des-Saints", "saint michel du squatec": "Saint-Michel-du-Squatec", "saint modeste": "Saint-Modeste", "saint moise": "Saint-Moïse", "saint narcisse": "Saint-Narcisse", "saint narcisse de beaurivage": "Saint-Narcisse-de-Beaurivage", "saint narcisse de rimouski": "Saint-Narcisse-de-Rimouski", "saint nazaire": "Saint-Nazaire", "saint nazaire d acton": "Saint-Nazaire-d'Acton", "saint nazaire de dorchester": "Saint-Nazaire-de-Dorchester", "saint neree de bellechasse": "Saint-Nérée-de-Bellechasse", "saint noel": "Saint-Noël", "saint norbert": "Saint-Norbert", "saint norbert d arthabaska": "Saint-Norbert-d'Arthabaska", "saint octave de metis": "Saint-Octave-de-Métis", "saint odilon de cranbourne": "Saint-Odilon-de-Cranbourne", "saint omer": "Saint-Omer", "saint ours": "Saint-Ours", "saint pacome": "Saint-Pacôme", "saint pamphile": "Saint-Pamphile", "saint pascal": "Saint-Pascal", "saint patrice de beaurivage": "Saint-Patrice-de-Beaurivage", "saint patrice de sherrington": "Saint-Patrice-de-Sherrington", "saint paul": "Saint-Paul", "saint paul d abbotsford": "Saint-Paul-d'Abbotsford", "saint paul de l ile aux noix": "Saint-Paul-de-l'Île-aux-Noix", "saint paul de la croix": "Saint-Paul-de-la-Croix", "saint paul de montminy": "Saint-Paul-de-Montminy", "saint paulin": "Saint-Paulin", "saint philemon": "Saint-Philémon", "saint philibert": "Saint-Philibert", "saint philippe": "Saint-Philippe", "saint philippe de neri": "Saint-Philippe-de-Néri", "saint pie": "Saint-Pie", "saint pie de guire": "Saint-Pie-de-Guire", "saint pierre": "Saint-Pierre", "saint pierre baptiste": "Saint-Pierre-Baptiste", "saint pierre de broughton": "Saint-Pierre-de-Broughton", "saint pierre de l ile d orleans": "Saint-Pierre-de-l'Île-d'Orléans", "saint pierre de la riviere du sud": "Saint-Pierre-de-la-Rivière-du-Sud", "saint pierre de lamy": "Saint-Pierre-de-Lamy", "saint pierre les becquets": "Saint-Pierre-les-Becquets", "saint placide": "Saint-Placide", "saint polycarpe": "Saint-Polycarpe", "saint prime": "Saint-Prime", "saint prosper": "Saint-Prosper", "saint prosper de champlain": "Saint-Prosper-de-Champlain", "saint raphael": "Saint-Raphaël", "saint raymond": "Saint-Raymond", "saint remi": "Saint-Rémi", "saint remi de tingwick": "Saint-Rémi-de-Tingwick", "saint rene": "Saint-René", "saint rene de matane": "Saint-René-de-Matane", "saint robert": "Saint-Robert", "saint robert bellarmin": "Saint-Robert-Bellarmin", "saint roch de l achigan": "Saint-Roch-de-l'Achigan", "saint roch de mekinac": "Saint-Roch-de-Mékinac", "saint roch de richelieu": "Saint-Roch-de-Richelieu", "saint roch des aulnaies": "Saint-Roch-des-Aulnaies", "saint roch ouest": "Saint-Roch-Ouest", "saint romain": "Saint-Romain", "saint rosaire": "Saint-Rosaire", "saint samuel": "Saint-Samuel", "saint sauveur": "Saint-Sauveur", "saint sebastien": "Saint-Sébastien", "saint severe": "Saint-Sévère", "saint severin": "Saint-Séverin", "saint simeon": "Saint-Siméon", "saint simon": "Saint-Simon", "saint simon de rimouski": "Saint-Simon-de-Rimouski", "saint simon les mines": "Saint-Simon-les-Mines", "saint sixte": "Saint-Sixte", "saint stanislas": "Saint-Stanislas", "saint stanislas de kostka": "Saint-Stanislas-de-Kostka", "saint sulpice": "Saint-Sulpice", "saint sylvere": "Saint-Sylvère", "saint sylvestre": "Saint-Sylvestre", "saint telesphore": "Saint-Télesphore", "saint tharcisius": "Saint-Tharcisius", "saint theodore d acton": "Saint-Théodore-d'Acton", "saint theophile": "Saint-Théophile", "saint thomas": "Saint-Thomas", "saint thomas didyme": "Saint-Thomas-Didyme", "saint thuribe": "Saint-Thuribe", "saint tite": "Saint-Tite", "saint tite des caps": "Saint-Tite-des-Caps", "saint ubalde": "Saint-Ubalde", "saint ulric": "Saint-Ulric", "saint urbain": "Saint-Urbain", "saint urbain premier": "Saint-Urbain-Premier", "saint valentin": "Saint-Valentin", "saint valere": "Saint-Valère", "saint valerien": "Saint-Valérien", "saint valerien de milton": "Saint-Valérien-de-Milton", "saint vallier": "Saint-Vallier", "saint venant de paquette": "Saint-Venant-de-Paquette", "saint vianney": "Saint-Vianney", "saint victor": "Saint-Victor", "saint wenceslas": "Saint-Wenceslas", "saint zacharie": "Saint-Zacharie", "saint zenon": "Saint-Zénon", "saint zenon du lac humqui": "Saint-Zénon-du-Lac-Humqui", "saint zephirin de courval": "Saint-Zéphirin-de-Courval", "saint zotique": "Saint-Zotique", "sainte adele": "Sainte-Adèle", "sainte agathe de lotbiniere": "Sainte-Agathe-de-Lotbinière", "sainte agathe des monts": "Sainte-Agathe-des-Monts", "sainte angele de merici": "Sainte-Angèle-de-Mérici", "sainte angele de monnoir": "Sainte-Angèle-de-Monnoir", "sainte angele de premont": "Sainte-Angèle-de-Prémont", "sainte anne de beaupre": "Sainte-Anne-de-Beaupré", "sainte anne de bellevue": "Sainte-Anne-de-Bellevue", "sainte anne de la perade": "Sainte-Anne-de-la-Pérade", "sainte anne de la rochelle": "Sainte-Anne-de-la-Rochelle", "sainte anne de sabrevois": "Sainte-Anne-de-Sabrevois", "sainte anne de sorel": "Sainte-Anne-de-Sorel", "sainte anne des lacs": "Sainte-Anne-des-Lacs", "sainte anne des monts": "Sainte-Anne-des-Monts", "sainte anne des plaines": "Sainte-Anne-des-Plaines", "sainte anne du lac": "Sainte-Anne-du-Lac", "sainte apolline de patton": "Sainte-Apolline-de-Patton", "sainte aurelie": "Sainte-Aurélie", "sainte barbe": "Sainte-Barbe", "sainte beatrix": "Sainte-Béatrix", "sainte brigide d iberville": "Sainte-Brigide-d'Iberville", "sainte brigitte de laval": "Sainte-Brigitte-de-Laval", "sainte brigitte des saults": "Sainte-Brigitte-des-Saults", "sainte catherine": "Sainte-Catherine", "sainte catherine de hatley": "Sainte-Catherine-de-Hatley", "sainte catherine de la jacques cartier": "Sainte-Catherine-de-la-Jacques-Cartier", "sainte cecile de levrard": "Sainte-Cécile-de-Lévrard", "sainte cecile de milton": "Sainte-Cécile-de-Milton", "sainte cecile de whitton": "Sainte-Cécile-de-Whitton", "sainte christine": "Sainte-Christine", "sainte christine d auvergne": "Sainte-Christine-d'Auvergne", "sainte claire": "Sainte-Claire", "sainte clotilde": "Sainte-Clotilde", "sainte clotilde de beauce": "Sainte-Clotilde-de-Beauce", "sainte clotilde de horton": "Sainte-Clotilde-de-Horton", "sainte croix": "Sainte-Croix", "sainte edwidge de clifton": "Sainte-Edwidge-de-Clifton", "sainte elisabeth": "Sainte-Élisabeth", "sainte elisabeth de proulx": "Sainte-Élisabeth-de-Proulx", "sainte elizabeth de warwick": "Sainte-Élizabeth-de-Warwick", "sainte emelie de l energie": "Sainte-Émélie-de-l'Énergie", "sainte eulalie": "Sainte-Eulalie", "sainte euphemie sur riviere du sud": "Sainte-Euphémie-sur-Rivière-du-Sud", "sainte famille de l ile d orleans": "Sainte-Famille-de-l'Île-d'Orléans", "sainte felicite": "Sainte-Félicité", "sainte flavie": "Sainte-Flavie", "sainte florence": "Sainte-Florence", "sainte foy sillery cap rouge": "Sainte-Foy--Sillery--Cap-Rouge", "sainte francoise": "Sainte-Françoise", "sainte genevieve de batiscan": "Sainte-Geneviève-de-Batiscan", "sainte genevieve de berthier": "Sainte-Geneviève-de-Berthier", "sainte germaine boule": "Sainte-Germaine-Boulé", "sainte gertrude manneville": "Sainte-Gertrude-Manneville", "sainte hedwidge": "Sainte-Hedwidge", "sainte helene de bagot": "Sainte-Hélène-de-Bagot", "sainte helene de chester": "Sainte-Hélène-de-Chester", "sainte helene de kamouraska": "Sainte-Hélène-de-Kamouraska", "sainte helene de mancebourg": "Sainte-Hélène-de-Mancebourg", "sainte henedine": "Sainte-Hénédine", "sainte irene": "Sainte-Irène", "sainte jeanne d arc": "Sainte-Jeanne-d'Arc", "sainte jeanne d arc de la mitis": "Sainte-Jeanne-d'Arc-de-la-Mitis", "sainte julie": "Sainte-Julie", "sainte julienne": "Sainte-Julienne", "sainte justine": "Sainte-Justine", "sainte justine de newton": "Sainte-Justine-de-Newton", "sainte louise": "Sainte-Louise", "sainte luce": "Sainte-Luce", "sainte lucie de beauregard": "Sainte-Lucie-de-Beauregard", "sainte lucie des laurentides": "Sainte-Lucie-des-Laurentides", "sainte madeleine": "Sainte-Madeleine", "sainte madeleine de la riviere madeleine": "Sainte-Madeleine-de-la-Rivière-Madeleine", "sainte marcelline de kildare": "Sainte-Marcelline-de-Kildare", "sainte marguerite": "Sainte-Marguerite", "sainte marguerite du lac masson": "Sainte-Marguerite-du-Lac-Masson", "sainte marguerite marie": "Sainte-Marguerite-Marie", "sainte marie": "Sainte-Marie", "sainte marie de blandford": "Sainte-Marie-de-Blandford", "sainte marie madeleine": "Sainte-Marie-Madeleine", "sainte marie salome": "Sainte-Marie-Salomé", "sainte marthe": "Sainte-Marthe", "sainte marthe sur le lac": "Sainte-Marthe-sur-le-Lac", "sainte martine": "Sainte-Martine", "sainte melanie": "Sainte-Mélanie", "sainte monique": "Sainte-Monique", "sainte paule": "Sainte-Paule", "sainte perpetue": "Sainte-Perpétue", "sainte petronille": "Sainte-Pétronille", "sainte praxede": "Sainte-Praxède", "sainte rita": "Sainte-Rita", "sainte rose de watford": "Sainte-Rose-de-Watford", "sainte rose du nord": "Sainte-Rose-du-Nord", "sainte sabine": "Sainte-Sabine", "sainte seraphine": "Sainte-Séraphine", "sainte sophie": "Sainte-Sophie", "sainte sophie d halifax": "Sainte-Sophie-d'Halifax", "sainte sophie de levrard": "Sainte-Sophie-de-Lévrard", "sainte thecle": "Sainte-Thècle", "sainte therese": "Sainte-Thérèse", "sainte therese de gaspe": "Sainte-Thérèse-de-Gaspé", "sainte therese de la gatineau": "Sainte-Thérèse-de-la-Gatineau", "sainte ursule": "Sainte-Ursule", "sainte victoire de sorel": "Sainte-Victoire-de-Sorel", "saints anges": "Saints-Anges", "saints martyrs canadiens": "Saints-Martyrs-Canadiens", "salaberry de valleyfield": "Salaberry-de-Valleyfield", "salluit": "Salluit", "sault au cochon": "Sault-au-Cochon", "sayabec": "Sayabec", "schefferville": "Schefferville", "scotstown": "Scotstown", "scott": "Scott", "senneterre": "Senneterre", "senneville": "Senneville", "sept iles": "Sept-Îles", "shannon": "Shannon", "shawinigan": "Shawinigan", "shawville": "Shawville", "sheenboro": "Sheenboro", "shefford": "Shefford", "sherbrooke": "Sherbrooke", "shigawake": "Shigawake", "sorel tracy": "Sorel-Tracy", "stanbridge east": "Stanbridge East", "stanbridge station": "Stanbridge Station", "stanstead": "Stanstead", "stanstead est": "Stanstead-Est", "stoke": "Stoke", "stoneham et tewkesbury": "Stoneham-et-Tewkesbury", "stornoway": "Stornoway", "stratford": "Stratford", "stukely sud": "Stukely-Sud", "sutton": "Sutton", "tadoussac": "Tadoussac", "taschereau": "Taschereau", "tasiujaq": "Tasiujaq", "temiscaming": "Témiscaming", "temiscouata sur le lac": "Témiscouata-sur-le-Lac", "terrasse vaudreuil": "Terrasse-Vaudreuil", "terrebonne": "Terrebonne", "thetford mines": "Thetford Mines", "thorne": "Thorne", "thurso": "Thurso", "timiskaming": "Timiskaming", "tingwick": "Tingwick", "toponyme a venir": "Toponyme à venir", "tourville": "Tourville", "trecesson": "Trécesson", "tres saint redempteur": "Très-Saint-Rédempteur", "tres saint sacrement": "Très-Saint-Sacrement", "tring jonction": "Tring-Jonction", "trois pistoles": "Trois-Pistoles", "trois rives": "Trois-Rives", "trois rivieres": "Trois-Rivières", "uashat": "Uashat", "ulverton": "Ulverton", "umiujaq": "Umiujaq", "upton": "Upton", "val alain": "Val-Alain", "val brillant": "Val-Brillant", "val d or": "Val-d'Or", "val david": "Val-David", "val des bois": "Val-des-Bois", "val des lacs": "Val-des-Lacs", "val des monts": "Val-des-Monts", "val des sources": "Val-des-Sources", "val joli": "Val-Joli", "val morin": "Val-Morin", "val racine": "Val-Racine", "val saint gilles": "Val-Saint-Gilles", "valcourt": "Valcourt", "vallee jonction": "Vallée-Jonction", "varennes": "Varennes", "vaudreuil dorion": "Vaudreuil-Dorion", "vaudreuil sur le lac": "Vaudreuil-sur-le-Lac", "venise en quebec": "Venise-en-Québec", "vercheres": "Verchères", "verdun": "Verdun", "victoriaville": "Victoriaville", "ville marie": "Ville-Marie", "villeray saint michel parc extension": "Villeray--Saint-Michel--Parc-Extension", "villeroy": "Villeroy", "waltham": "Waltham", "warden": "Warden", "warwick": "Warwick", "waskaganish": "Waskaganish", "waswanipi": "Waswanipi", "waterloo": "Waterloo", "waterville": "Waterville", "weedon": "Weedon", "wemindji": "Wemindji", "wemotaci": "Wemotaci", "wendake": "Wendake", "wentworth": "Wentworth", "wentworth nord": "Wentworth-Nord", "westbury": "Westbury", "westmount": "Westmount", "whapmagoostui": "Whapmagoostui", "wickham": "Wickham", "windsor": "Windsor", "wolinak": "Wôlinak", "wotton": "Wotton", "yamachiche": "Yamachiche", "yamaska": "Yamaska"}}
\ No newline at end of file
modified sortika/db.py +98 −11
@@ -11,9 +11,10 @@ import json
11 import sqlite3 11 import sqlite3
12 import statistics 12 import statistics
13 import time 13 import time
14+from datetime import date
14 from pathlib import Path 15 from pathlib import Path
15 16
16−from .schema import Event 17+from .schema import Event, quarantine_reason
17 18
18 DB_PATH = Path(__file__).resolve().parent.parent / "data" / "sortika.db" 19 DB_PATH = Path(__file__).resolve().parent.parent / "data" / "sortika.db"
19 20
@@ -49,6 +50,9 @@ CREATE TABLE IF NOT EXISTS events (
49 start_date TEXT, 50 start_date TEXT,
50 start_time TEXT, -- "HH:MM" locale QC (NULL = inconnue) 51 start_time TEXT, -- "HH:MM" locale QC (NULL = inconnue)
51 end_date TEXT, 52 end_date TEXT,
53+ end_time TEXT, -- "HH:MM" locale QC (NULL = inconnue)
54+ status TEXT, -- cancelled/postponed/soldout (NULL = prévu)
55+ quarantine TEXT, -- raison de quarantaine (NULL = publiable)
52 artists TEXT, -- JSON (artistes à l'affiche) 56 artists TEXT, -- JSON (artistes à l'affiche)
53 is_free INTEGER, -- 1 gratuit / 0 payant / NULL inconnu 57 is_free INTEGER, -- 1 gratuit / 0 payant / NULL inconnu
54 price_min REAL, 58 price_min REAL,
@@ -111,9 +115,15 @@ def connect(path: Path | str | None = None) -> sqlite3.Connection:
111 return con 115 return con
112 116
113 117
114−# Colonnes additives (vague 2) : les bases créées avant leur ajout sont 118+# Colonnes additives (vague 2 : start_time/artists ; Phase 2 : end_time,
115−# migrées à la connexion — jamais de changement destructif. 119+# status, quarantine) : les bases créées avant leur ajout sont migrées à la
116−_ADDITIVE_COLUMNS = {"start_time": "TEXT", "artists": "TEXT"} 120+# connexion — jamais de changement destructif.
121+_ADDITIVE_COLUMNS = {
122+ "start_time": "TEXT", "artists": "TEXT",
123+ "end_time": "TEXT", # "HH:MM" locale QC (NULL = inconnue)
124+ "status": "TEXT", # cancelled / postponed / soldout (NULL = prévu)
125+ "quarantine": "TEXT", # raison de quarantaine (NULL = publiable)
126+}
117 127
118 128
119 def _migrate(con: sqlite3.Connection) -> None: 129 def _migrate(con: sqlite3.Connection) -> None:
@@ -124,7 +134,7 @@ def _migrate(con: sqlite3.Connection) -> None:
124 con.commit() 134 con.commit()
125 135
126 136
127−def _event_row(ev: Event, now: float) -> dict: 137+def _event_row(ev: Event, now: float, today: str) -> dict:
128 return { 138 return {
129 "uid": ev.uid, "source": ev.source, "external_id": ev.external_id, 139 "uid": ev.uid, "source": ev.source, "external_id": ev.external_id,
130 "url": ev.url, "title": ev.title, "description": ev.description, 140 "url": ev.url, "title": ev.title, "description": ev.description,
@@ -134,7 +144,9 @@ def _event_row(ev: Event, now: float) -> dict:
134 "city": ev.city, "region": ev.region, "tourist_region": ev.tourist_region, 144 "city": ev.city, "region": ev.region, "tourist_region": ev.tourist_region,
135 "postal_code": ev.postal_code, "lat": ev.lat, "lng": ev.lng, 145 "postal_code": ev.postal_code, "lat": ev.lat, "lng": ev.lng,
136 "start_date": ev.start_date, "start_time": ev.start_time, 146 "start_date": ev.start_date, "start_time": ev.start_time,
137− "end_date": ev.end_date, 147+ "end_date": ev.end_date, "end_time": ev.end_time,
148+ "status": ev.status or None,
149+ "quarantine": quarantine_reason(ev, today),
138 "artists": json.dumps(ev.artists, ensure_ascii=False), 150 "artists": json.dumps(ev.artists, ensure_ascii=False),
139 "is_free": (None if ev.is_free is None else int(ev.is_free)), 151 "is_free": (None if ev.is_free is None else int(ev.is_free)),
140 "price_min": ev.price_min, "price_label": ev.price_label, 152 "price_min": ev.price_min, "price_label": ev.price_label,
@@ -161,9 +173,10 @@ def sync_source(con: sqlite3.Connection, source: str, events: list[Event]) -> di
161 alert = (f"dérive : {len(events)} événements reçus vs médiane {med:.0f} " 173 alert = (f"dérive : {len(events)} événements reçus vs médiane {med:.0f} "
162 f"— retraits suspendus (connecteur probablement cassé)") 174 f"— retraits suspendus (connecteur probablement cassé)")
163 175
176+ today = date.today().isoformat()
164 seen: set[str] = set() 177 seen: set[str] = set()
165 for ev in events: 178 for ev in events:
166− row = _event_row(ev, now) 179+ row = _event_row(ev, now, today)
167 seen.add(ev.uid) 180 seen.add(ev.uid)
168 cur = con.execute("SELECT content_hash FROM events WHERE uid=?", (ev.uid,)) 181 cur = con.execute("SELECT content_hash FROM events WHERE uid=?", (ev.uid,))
169 old = cur.fetchone() 182 old = cur.fetchone()
@@ -172,14 +185,16 @@ def sync_source(con: sqlite3.Connection, source: str, events: list[Event]) -> di
172 """INSERT INTO events (uid, source, external_id, url, title, 185 """INSERT INTO events (uid, source, external_id, url, title,
173 description, categories, raw_categories, audience, venue, 186 description, categories, raw_categories, audience, venue,
174 address, city, region, tourist_region, postal_code, lat, lng, 187 address, city, region, tourist_region, postal_code, lat, lng,
175− start_date, start_time, end_date, artists, is_free, 188+ start_date, start_time, end_date, end_time, status,
189+ quarantine, artists, is_free,
176 price_min, price_label, 190 price_min, price_label,
177 organizer, website, image, dedup_key, content_hash, 191 organizer, website, image, dedup_key, content_hash,
178 first_seen, last_seen, updated_at, miss_count, active) 192 first_seen, last_seen, updated_at, miss_count, active)
179 VALUES (:uid,:source,:external_id,:url,:title,:description, 193 VALUES (:uid,:source,:external_id,:url,:title,:description,
180 :categories,:raw_categories,:audience,:venue,:address,:city, 194 :categories,:raw_categories,:audience,:venue,:address,:city,
181 :region,:tourist_region,:postal_code,:lat,:lng,:start_date, 195 :region,:tourist_region,:postal_code,:lat,:lng,:start_date,
182− :start_time,:end_date,:artists,:is_free,:price_min, 196+ :start_time,:end_date,:end_time,:status,:quarantine,
197+ :artists,:is_free,:price_min,
183 :price_label,:organizer, 198 :price_label,:organizer,
184 :website,:image,:dedup_key,:content_hash, 199 :website,:image,:dedup_key,:content_hash,
185 :now,:now,:now,0,1)""", row) 200 :now,:now,:now,0,1)""", row)
@@ -193,6 +208,7 @@ def sync_source(con: sqlite3.Connection, source: str, events: list[Event]) -> di
193 tourist_region=:tourist_region, postal_code=:postal_code, 208 tourist_region=:tourist_region, postal_code=:postal_code,
194 lat=:lat, lng=:lng, start_date=:start_date, 209 lat=:lat, lng=:lng, start_date=:start_date,
195 start_time=:start_time, end_date=:end_date, 210 start_time=:start_time, end_date=:end_date,
211+ end_time=:end_time, status=:status, quarantine=:quarantine,
196 artists=:artists, is_free=:is_free, price_min=:price_min, 212 artists=:artists, is_free=:is_free, price_min=:price_min,
197 price_label=:price_label, organizer=:organizer, 213 price_label=:price_label, organizer=:organizer,
198 website=:website, image=:image, dedup_key=:dedup_key, 214 website=:website, image=:image, dedup_key=:dedup_key,
@@ -200,8 +216,14 @@ def sync_source(con: sqlite3.Connection, source: str, events: list[Event]) -> di
200 miss_count=0, active=1 WHERE uid=:uid""", row) 216 miss_count=0, active=1 WHERE uid=:uid""", row)
201 stats["updated"] += 1 217 stats["updated"] += 1
202 else: 218 else:
203− con.execute("UPDATE events SET last_seen=?, miss_count=0, active=1 " 219+ # inchangé : on rafraîchit la présence ; la réactivation (active=1)
204− "WHERE uid=?", (now, ev.uid)) 220+ # ne s'applique qu'aux événements non terminés — un passé archivé
221+ # que la source publie encore RESTE archivé (règle d'archivage).
222+ con.execute(
223+ "UPDATE events SET last_seen=?, miss_count=0, "
224+ "active = CASE WHEN coalesce(end_date, start_date) >= ? "
225+ "OR coalesce(end_date, start_date) IS NULL THEN 1 ELSE active END "
226+ "WHERE uid=?", (now, today, ev.uid))
205 stats["unchanged"] += 1 227 stats["unchanged"] += 1
206 228
207 # Retraits (avec délai de grâce), sauf en cas de dérive détectée. 229 # Retraits (avec délai de grâce), sauf en cas de dérive détectée.
@@ -230,6 +252,71 @@ def sync_source(con: sqlite3.Connection, source: str, events: list[Event]) -> di
230 return stats 252 return stats
231 253
232 254
255+def archive_past_events(con: sqlite3.Connection, today: str | None = None) -> int:
256+ """Règle d'archivage temporel (Phase 2) : un événement TERMINÉ (date de fin
257+ — ou de début à défaut — strictement avant aujourd'hui) est archivé
258+ (active=0), jamais supprimé. Appelée à chaque cycle d'ingestion ; les
259+ événements sans aucune date ne sont pas touchés. Retourne le nombre
260+ d'événements archivés."""
261+ today = today or date.today().isoformat()
262+ cur = con.execute(
263+ "UPDATE events SET active=0, updated_at=? WHERE active=1 "
264+ "AND coalesce(end_date, start_date) < ?",
265+ (time.time(), today))
266+ con.commit()
267+ return cur.rowcount
268+
269+
270+def future_counts(con: sqlite3.Connection, today: str | None = None) -> dict:
271+ """Événements FUTURS actifs par source — la métrique de santé qui manquait
272+ (une source « verte » peut mourir en silence : Laval, max 2025-12-09)."""
273+ today = today or date.today().isoformat()
274+ return {r["source"]: r["n"] for r in con.execute(
275+ "SELECT source, COUNT(*) AS n FROM events WHERE active=1 "
276+ "AND quarantine IS NULL AND coalesce(end_date, start_date) >= ? "
277+ "GROUP BY source", (today,))}
278+
279+
280+# seuil de chute du volume futur d'une source entre deux cycles → alerte
281+FUTURE_DROP_RATIO = 0.2 # il reste moins de 20 % du volume précédent
282+
283+_FUTURE_STATE = Path(__file__).resolve().parent.parent / "data" / "future_counts.json"
284+
285+
286+def check_future_health(con: sqlite3.Connection) -> list[str]:
287+ """Alerte « source qui meurt en silence » : 0 événement futur, ou chute de
288+ plus de 80 % par rapport au cycle précédent. État persisté sur disque
289+ (data/future_counts.json) ; les alertes sont retournées (et affichées par
290+ le pipeline) + exposées via /api/sources (canal lu par la supervision)."""
291+ counts = future_counts(con)
292+ try:
293+ previous = json.loads(_FUTURE_STATE.read_text(encoding="utf-8"))
294+ except Exception:
295+ previous = {}
296+ alerts: list[str] = []
297+ # toutes les sources connues de la base (une source à 0 futur n'apparaît
298+ # pas dans counts — c'est justement elle qu'il faut attraper)
299+ known = ({r[0] for r in con.execute("SELECT DISTINCT source FROM events")}
300+ | set(counts) | {s for s in previous if s != "_alerts"})
301+ for source in sorted(known):
302+ n, prev = counts.get(source, 0), previous.get(source)
303+ if n == 0:
304+ alerts.append(f"{source} : 0 événement futur — source morte "
305+ "ou connecteur cassé en silence")
306+ elif isinstance(prev, int) and prev > 0 and n <= prev * FUTURE_DROP_RATIO:
307+ alerts.append(f"{source} : chute des événements futurs "
308+ f"{prev} → {n} (> 80 %)")
309+ state = dict(counts)
310+ state["_alerts"] = alerts
311+ try:
312+ _FUTURE_STATE.parent.mkdir(parents=True, exist_ok=True)
313+ _FUTURE_STATE.write_text(json.dumps(state, ensure_ascii=False),
314+ encoding="utf-8")
315+ except Exception:
316+ pass
317+ return alerts
318+
319+
233 def log_failure(con: sqlite3.Connection, source: str, error: str) -> None: 320 def log_failure(con: sqlite3.Connection, source: str, error: str) -> None:
234 con.execute("INSERT INTO sync_log (source, ts, fetched, added, updated, " 321 con.execute("INSERT INTO sync_log (source, ts, fetched, added, updated, "
235 "removed, error) VALUES (?,?,0,0,0,0,?)", 322 "removed, error) VALUES (?,?,0,0,0,0,?)",
modified sortika/geocode.py +45 −24
@@ -132,36 +132,57 @@ def run(con: sqlite3.Connection, max_requests: int = GEOCODE_MAX) -> dict:
132 stats = {"events_filled": 0, "lookups": 0, "hits": 0, "misses": 0, 132 stats = {"events_filled": 0, "lookups": 0, "hits": 0, "misses": 0,
133 "cached_places": len(cache)} 133 "cached_places": len(cache)}
134 134
135+ # Phase 2 : l'ADRESSE CIVIQUE prime quand la source la publie (Brossard :
136+ # 94 % d'adresses mais 0,7 % de GPS via salle+ville ; Longueuil : 100 %
137+ # d'adresses). Repli : salle + ville (comportement historique).
135 rows = con.execute( 138 rows = con.execute(
136− "SELECT DISTINCT venue, city FROM events WHERE active=1 " 139+ "SELECT DISTINCT venue, address, city FROM events WHERE active=1 "
137− "AND lat IS NULL AND venue != '' AND city != '' " 140+ "AND lat IS NULL AND city != '' AND (venue != '' OR address != '') "
138− "ORDER BY venue, city").fetchall() 141+ "ORDER BY address DESC, venue, city").fetchall()
139 142
140 for row in rows: 143 for row in rows:
141− venue, city = row["venue"], row["city"] 144+ venue, address, city = row["venue"], row["address"], row["city"]
142− key = _cache_key(venue, city) 145+ # candidats dans l'ordre de précision : adresse civique, puis salle
143− entry = cache.get(key) 146+ candidates = []
144− if entry is None or (not entry.get("ok") 147+ if address:
145− and now - entry.get("ts", 0) > RETRY_FAIL_AFTER): 148+ candidates.append(("addr:" + _cache_key(address, city),
146− if stats["lookups"] >= max_requests: 149+ "addr", address))
147− continue # plafond atteint : salle re-tentée au prochain cycle 150+ if venue:
148− try: 151+ candidates.append((_cache_key(venue, city), "venue", venue))
149− found, used = _lookup(session, venue, city) 152+ entry = None
150− except Exception: # réseau/HTTP : ne bloque pas le cycle, pas mémorisé 153+ for key, kind, query in candidates:
151− stats["lookups"] += 1 154+ entry = cache.get(key)
152− continue 155+ if entry is None or (not entry.get("ok")
153− stats["lookups"] += used 156+ and now - entry.get("ts", 0) > RETRY_FAIL_AFTER):
154− if found: 157+ if stats["lookups"] >= max_requests:
155− entry = {"ok": True, "lat": found[0], "lng": found[1], "ts": now} 158+ entry = None
156− stats["hits"] += 1 159+ break # plafond atteint : lieu re-tenté au prochain cycle
157− else: # échec mémorisé (re-tenté après 30 jours) 160+ try:
158− entry = {"ok": False, "ts": now} 161+ if kind == "addr":
159− stats["misses"] += 1 162+ found = _nominatim(
160− cache[key] = entry 163+ session, f"{query}, {city}, Québec, Canada", city)
164+ used = 1
165+ else:
166+ found, used = _lookup(session, query, city)
167+ except Exception: # réseau/HTTP : ne bloque pas le cycle, pas mémorisé
168+ stats["lookups"] += 1
169+ entry = None
170+ continue
171+ stats["lookups"] += used
172+ if found:
173+ entry = {"ok": True, "lat": found[0], "lng": found[1], "ts": now}
174+ stats["hits"] += 1
175+ else: # échec mémorisé (re-tenté après 30 jours)
176+ entry = {"ok": False, "ts": now}
177+ stats["misses"] += 1
178+ cache[key] = entry
179+ if entry and entry.get("ok"):
180+ break # premier candidat résolu = le plus précis
161 if entry and entry.get("ok"): 181 if entry and entry.get("ok"):
162 cur = con.execute( 182 cur = con.execute(
163 "UPDATE events SET lat=?, lng=? WHERE active=1 AND lat IS NULL " 183 "UPDATE events SET lat=?, lng=? WHERE active=1 AND lat IS NULL "
164− "AND venue=? AND city=?", (entry["lat"], entry["lng"], venue, city)) 184+ "AND venue=? AND address=? AND city=?",
185+ (entry["lat"], entry["lng"], venue, address, city))
165 stats["events_filled"] += cur.rowcount 186 stats["events_filled"] += cur.rowcount
166 187
167 save_cache(cache) 188 save_cache(cache)
modified sortika/ingest.py +19 −1
@@ -47,8 +47,26 @@ def run(sources: list[str] | None = None) -> list[dict]:
47 db.log_failure(con, sid, f"{exc}") 47 db.log_failure(con, sid, f"{exc}")
48 traceback.print_exc() 48 traceback.print_exc()
49 results.append({"source": sid, "error": str(exc)}) 49 results.append({"source": sid, "error": str(exc)})
50+ # Cycle de vie temporel (Phase 2) : les événements terminés sont archivés
51+ # (active=0, jamais supprimés) à chaque cycle — les sources qui publient
52+ # leur historique (montreal, sitq, laval…) ne gonflent plus l'inventaire.
53+ try:
54+ archived = db.archive_past_events(con)
55+ if archived:
56+ print(f"[sorti-ka] archivage : {archived} événement(s) terminé(s) → active=0")
57+ except Exception:
58+ traceback.print_exc()
59+ # Santé « futurs » (Phase 2) : une source verte peut mourir en silence
60+ # (Laval : 41 runs sans erreur, max(date)=2025-12-09). Alerte si une
61+ # source n'a plus AUCUN événement futur ou chute de plus de 80 %.
62+ try:
63+ for alert in db.check_future_health(con):
64+ print(f"[sorti-ka] ⚠ ALERTE futurs — {alert}")
65+ except Exception:
66+ traceback.print_exc()
50 # Géocodage des salles (transversal) : remplit lat/lng des événements 67 # Géocodage des salles (transversal) : remplit lat/lng des événements
51− # actifs sans GPS (salle + ville connues) — Nominatim + cache disque, 68+ # actifs sans GPS (salle + ville connues ; adresse civique en priorité
69+ # quand la source la publie) — Nominatim + cache disque,
52 # plafond de requêtes réseau par cycle (voir sortika/geocode.py). 70 # plafond de requêtes réseau par cycle (voir sortika/geocode.py).
53 try: 71 try:
54 gstats = geocode.run(con) 72 gstats = geocode.run(con)
modified sortika/normalize.py +35 −2
@@ -40,12 +40,45 @@ def _key(s: str) -> str:
40 return strip_accents(clean_text(s or "")).lower() 40 return strip_accents(clean_text(s or "")).lower()
41 41
42 42
43+# Noms canoniques des municipalités/arrondissements du Québec (Répertoire
44+# MAMH, données ouvertes) : « Montreal » → « Montréal », « Quebec » →
45+# « Québec », « LEVIS » → « Lévis »… Les sources anglophones/dégradées
46+# éclataient les facettes ville (Phase 1 : Montreal 242 vs Montréal 5 249).
47+_CITY_CANON: dict | None = None
48+
49+
50+def _city_canon_table() -> dict:
51+ global _CITY_CANON
52+ if _CITY_CANON is None:
53+ import json
54+ from pathlib import Path
55+ p = Path(__file__).resolve().parent.parent / "data" / "villes_canon.json"
56+ try:
57+ _CITY_CANON = json.loads(p.read_text(encoding="utf-8"))["villes"]
58+ except Exception:
59+ _CITY_CANON = {}
60+ return _CITY_CANON
61+
62+
63+def canonical_city(city: str) -> str:
64+ """Nom officiel (accentué) d'une ville du Répertoire MAMH, sinon inchangé."""
65+ if not city:
66+ return city
67+ from .regions import _k
68+ key = _k(city)
69+ table = _city_canon_table()
70+ hit = table.get(key) or table.get(
71+ re.sub(r"\bste\b", "sainte", re.sub(r"\bst\b", "saint", key)))
72+ return hit or city
73+
74+
43 def clean_city(raw: str) -> str: 75 def clean_city(raw: str) -> str:
44− """Nettoie une ville : « Montréal (Québec) » → « Montréal », casse conservée.""" 76+ """Nettoie une ville : « Montréal (Québec) » → « Montréal », puis nom
77+ canonique du Répertoire MAMH (« Montreal » → « Montréal »)."""
45 s = clean_text(raw) 78 s = clean_text(raw)
46 s = re.sub(r"\s*\((qu[eé]bec|qc)\)\s*$", "", s, flags=re.I) 79 s = re.sub(r"\s*\((qu[eé]bec|qc)\)\s*$", "", s, flags=re.I)
47 s = re.sub(r",\s*(qu[eé]bec|qc|canada)\s*$", "", s, flags=re.I) 80 s = re.sub(r",\s*(qu[eé]bec|qc|canada)\s*$", "", s, flags=re.I)
48− return s.strip(" ,") 81+ return canonical_city(s.strip(" ,"))
49 82
50 83
51 # --------------------------------------------------------------------------- 84 # ---------------------------------------------------------------------------
modified sortika/schema.py +94 −3
@@ -29,7 +29,83 @@ from .normalize import (
29 ) 29 )
30 from .regions import is_valid_region, region_from_city, region_from_tourist 30 from .regions import is_valid_region, region_from_city, region_from_tourist
31 31
32−__all__ = ["Event"] 32+__all__ = ["Event", "normalize_status", "quarantine_reason"]
33+
34+# ---------------------------------------------------------------------------
35+# Statut du cycle de vie billetterie (vague Phase 2) : "" = prévu (défaut),
36+# et 3 états explicites remontés par les sources qui les publient
37+# (eventbrite is_cancelled, evenko representation_status, ticketmaster
38+# dates.status.code / cancelled, laval préfixe « ANNULÉ - » du titre).
39+# ---------------------------------------------------------------------------
40+STATUSES = ("", "cancelled", "postponed", "soldout")
41+
42+_STATUS_HINTS = (
43+ ("cancel", "cancelled"), ("annul", "cancelled"),
44+ ("postpon", "postponed"), ("report", "postponed"), ("rescheduled", "postponed"),
45+ ("sold_out", "soldout"), ("soldout", "soldout"), ("sold out", "soldout"),
46+ ("complet", "soldout"),
47+)
48+
49+
50+def normalize_status(raw: str | None) -> str:
51+ """Normalise un statut source vers la taxonomie interne — jamais inventé."""
52+ if not raw:
53+ return ""
54+ k = str(raw).strip().lower()
55+ if k in STATUSES:
56+ return k
57+ for hint, status in _STATUS_HINTS:
58+ if hint in k:
59+ return status
60+ return ""
61+
62+
63+# ---------------------------------------------------------------------------
64+# Quarantaine qualité (Phase 2) : règles anti-aberrations évaluées à chaque
65+# sync. Un événement en quarantaine reste en base (comptée, réintégrable dès
66+# que la donnée source redevient saine) mais n'est PAS publié.
67+# ---------------------------------------------------------------------------
68+
69+# villes canadiennes/américaines fréquentes hors Québec (clés sans accents) —
70+# un connecteur qui laisse passer une date hors province tombe ici.
71+_NON_QC_CITIES = {
72+ "ottawa", "toronto", "vancouver", "calgary", "edmonton", "winnipeg",
73+ "halifax", "moncton", "fredericton", "hamilton", "mississauga", "kingston",
74+ "london", "cornwall", "niagara falls", "st catharines", "windsor",
75+ "saskatoon", "regina", "victoria", "charlottetown", "st john s",
76+ "boston", "new york", "burlington", "plattsburgh", "paris",
77+}
78+
79+QUARANTINE_HORIZON_DAYS = 730 # date de début à plus de 2 ans = suspecte
80+
81+
82+def quarantine_reason(ev: "Event", today: str) -> str | None:
83+ """Raison de quarantaine, ou None si l'événement est publiable.
84+
85+ Règles (consignées Phase 2) :
86+ · date de début aberrante (avant 2000 — les passés « normaux » sont
87+ gérés par l'archivage, pas par la quarantaine) ;
88+ · date de début à plus de 2 ans dans le futur (erreur de saisie source) ;
89+ · ville manifestement hors Québec (liste de villes connues hors province).
90+ L'heure 00:00 n'est PAS une cause de quarantaine : elle est affichée
91+ « heure non confirmée » côté frontend. L'image 404 n'est pas vérifiée
92+ (coût réseau prohibitif) : le frontend retire les images cassées (onerror).
93+ """
94+ from datetime import date, timedelta
95+ if ev.start_date:
96+ if ev.start_date < "2000-01-01":
97+ return f"date de début aberrante ({ev.start_date})"
98+ try:
99+ horizon = (date.fromisoformat(today)
100+ + timedelta(days=QUARANTINE_HORIZON_DAYS)).isoformat()
101+ except ValueError:
102+ horizon = "2100-01-01"
103+ if ev.start_date > horizon:
104+ return f"date de début à plus de 2 ans ({ev.start_date})"
105+ from .normalize import _key
106+ if ev.city and _key(ev.city) in _NON_QC_CITIES:
107+ return f"ville hors Québec ({ev.city})"
108+ return None
33 109
34 110
35 @dataclass 111 @dataclass
@@ -55,6 +131,8 @@ class Event:
55 start_date: str | None = None # ISO "2026-09-11" 131 start_date: str | None = None # ISO "2026-09-11"
56 start_time: str | None = None # "HH:MM" locale QC (None = inconnue) 132 start_time: str | None = None # "HH:MM" locale QC (None = inconnue)
57 end_date: str | None = None # ISO "2026-09-13" 133 end_date: str | None = None # ISO "2026-09-13"
134+ end_time: str | None = None # "HH:MM" locale QC (None = inconnue)
135+ status: str = "" # "" (prévu) | cancelled | postponed | soldout
58 artists: list[str] = field(default_factory=list) # artistes à l'affiche 136 artists: list[str] = field(default_factory=list) # artistes à l'affiche
59 is_free: bool | None = None # gratuit (None = inconnu) 137 is_free: bool | None = None # gratuit (None = inconnu)
60 price_min: float | None = None # plus bas prix ($ CAD) si payant 138 price_min: float | None = None # plus bas prix ($ CAD) si payant
@@ -74,16 +152,27 @@ class Event:
74 return hashlib.sha256(blob.encode("utf-8")).hexdigest() 152 return hashlib.sha256(blob.encode("utf-8")).hexdigest()
75 153
76 def dedup_key(self) -> str: 154 def dedup_key(self) -> str:
77− """Empreinte inter-sources : titre normalisé + ville + date de début. 155+ """Empreinte inter-sources : titre normalisé + ville + date + HEURE.
78 156
79 Un même événement publié par deux sources (ex. SITQ et la ville) doit 157 Un même événement publié par deux sources (ex. SITQ et la ville) doit
80 produire la même clé pour être fusionné (patron Lou-Ka / section 12). 158 produire la même clé pour être fusionné (patron Lou-Ka / section 12).
159+
160+ RÈGLE (Phase 2, 2026-08-19) : l'heure de début fait partie de la clé.
161+ Un même titre le même jour dans la même ville mais à des heures
162+ DIFFÉRENTES = des séances distinctes (ex. lepointdevente publie 11
163+ séances horodatées d'un parcours le même jour) — la fusion d'affichage
164+ ne doit pas les écraser. Deux sources publiant la même séance à la
165+ même heure fusionnent toujours ; une fiche sans heure ne fusionne
166+ qu'avec les autres fiches sans heure (compromis assumé : mieux vaut
167+ un doublon visible que dix séances invisibles).
81 """ 168 """
82 from .normalize import strip_accents 169 from .normalize import strip_accents
83 t = strip_accents(self.title).lower() 170 t = strip_accents(self.title).lower()
84 t = "".join(c for c in t if c.isalnum()) 171 t = "".join(c for c in t if c.isalnum())
85 c = strip_accents(self.city).lower().replace(" ", "") 172 c = strip_accents(self.city).lower().replace(" ", "")
86− return hashlib.sha1(f"{t}|{c}|{self.start_date or ''}".encode()).hexdigest() 173+ return hashlib.sha1(
174+ f"{t}|{c}|{self.start_date or ''}|{self.start_time or ''}"
175+ .encode()).hexdigest()
87 176
88 def finalize(self) -> "Event": 177 def finalize(self) -> "Event":
89 """Applique la normalisation commune. Appelé par le pipeline d'ingestion. 178 """Applique la normalisation commune. Appelé par le pipeline d'ingestion.
@@ -101,6 +190,8 @@ class Event:
101 self.start_date = parse_date_iso(self.start_date) 190 self.start_date = parse_date_iso(self.start_date)
102 self.start_time = parse_time(self.start_time) 191 self.start_time = parse_time(self.start_time)
103 self.end_date = parse_date_iso(self.end_date) or self.start_date 192 self.end_date = parse_date_iso(self.end_date) or self.start_date
193+ self.end_time = parse_time(self.end_time)
194+ self.status = normalize_status(self.status)
104 195
105 # artistes : nettoyés, dédupliqués (ordre conservé), jamais inventés 196 # artistes : nettoyés, dédupliqués (ordre conservé), jamais inventés
106 seen: set[str] = set() 197 seen: set[str] = set()
modified tests/test_db.py +88 −4
@@ -69,14 +69,98 @@ def test_start_time_and_artists_persisted(tmp_path):
69 def test_migration_adds_columns(tmp_path): 69 def test_migration_adds_columns(tmp_path):
70 p = tmp_path / "old.db" 70 p = tmp_path / "old.db"
71 con = db.connect(p) 71 con = db.connect(p)
72− # simuler une base d'avant la vague 2 (sans les colonnes additives) 72+ # simuler une base d'avant la vague 2 / Phase 2 (sans colonnes additives)
73− con.execute("ALTER TABLE events DROP COLUMN start_time") 73+ for col in ("start_time", "artists", "end_time", "status", "quarantine"):
74− con.execute("ALTER TABLE events DROP COLUMN artists") 74+ con.execute(f"ALTER TABLE events DROP COLUMN {col}")
75 con.commit() 75 con.commit()
76 con.close() 76 con.close()
77 con = db.connect(p) # la connexion migre (colonnes additives) 77 con = db.connect(p) # la connexion migre (colonnes additives)
78 cols = {r["name"] for r in con.execute("PRAGMA table_info(events)")} 78 cols = {r["name"] for r in con.execute("PRAGMA table_info(events)")}
79− assert {"start_time", "artists"} <= cols 79+ assert {"start_time", "artists", "end_time", "status", "quarantine"} <= cols
80+
81+
82+def test_end_time_and_status_persisted(tmp_path):
83+ con = db.connect(tmp_path / "t.db")
84+ ev = Event(source="eventbrite", external_id="e1", url="https://x/e1",
85+ title="Atelier", city="Montréal", start_date="2027-01-15",
86+ start_time="19:00", end_time="21h30",
87+ status="CANCELLED").finalize()
88+ assert ev.end_time == "21:30" # normalisée "HH:MM"
89+ assert ev.status == "cancelled" # taxonomie interne
90+ db.sync_source(con, "eventbrite", [ev])
91+ row = con.execute("SELECT end_time, status FROM events").fetchone()
92+ assert row["end_time"] == "21:30" and row["status"] == "cancelled"
93+
94+
95+def test_archive_past_events(tmp_path):
96+ """Règle Phase 2 : passé = archivé (active=0), jamais supprimé — et un
97+ passé archivé n'est pas réactivé par un sync « unchanged »."""
98+ con = db.connect(tmp_path / "t.db")
99+ past = Event(source="sitq", external_id="p1", url="https://x/p1",
100+ title="Marché passé", city="Québec",
101+ start_date="2020-05-01", end_date="2020-05-02").finalize()
102+ future = _ev(eid="f1", title="Événement futur")
103+ db.sync_source(con, "sitq", [past, future])
104+ n = db.archive_past_events(con)
105+ assert n == 1
106+ rows = {r["uid"]: r["active"] for r in
107+ con.execute("SELECT uid, active FROM events")}
108+ assert rows["sitq:p1"] == 0 and rows["sitq:f1"] == 1
109+ # la source publie toujours le passé (inchangé) → il RESTE archivé
110+ db.sync_source(con, "sitq", [past, future])
111+ assert con.execute("SELECT active FROM events WHERE uid='sitq:p1'"
112+ ).fetchone()[0] == 0
113+
114+
115+def test_quarantine_rules(tmp_path):
116+ """Anti-aberrations : > 2 ans ou hors QC → quarantaine (réintégrable),
117+ comptée à part et absente des vues publiées."""
118+ con = db.connect(tmp_path / "t.db")
119+ far = Event(source="sitq", external_id="q1", url="https://x/q1",
120+ title="Trop loin", city="Québec",
121+ start_date="2031-01-01").finalize()
122+ hors = Event(source="bandsintown", external_id="q2", url="https://x/q2",
123+ title="Show à Toronto", city="Toronto",
124+ start_date="2026-10-01").finalize()
125+ ok = _ev(eid="q3", title="Événement sain")
126+ db.sync_source(con, "sitq", [far, ok])
127+ db.sync_source(con, "bandsintown", [hors])
128+ rows = {r["uid"]: r["quarantine"] for r in
129+ con.execute("SELECT uid, quarantine FROM events")}
130+ assert rows["sitq:q1"] and "2 ans" in rows["sitq:q1"]
131+ assert rows["bandsintown:q2"] and "hors Québec" in rows["bandsintown:q2"]
132+ assert rows["sitq:q3"] is None
133+
134+
135+def test_future_counts_and_alerts(tmp_path, monkeypatch):
136+ con = db.connect(tmp_path / "t.db")
137+ monkeypatch.setattr(db, "_FUTURE_STATE", tmp_path / "future_counts.json")
138+ past = Event(source="laval", external_id="m1", url="https://x/m1",
139+ title="Activité terminée", city="Laval",
140+ start_date="2025-12-09").finalize()
141+ db.sync_source(con, "laval", [past])
142+ db.sync_source(con, "sitq", [_ev(eid="ok")])
143+ counts = db.future_counts(con)
144+ assert counts.get("sitq") == 1 and "laval" not in counts
145+ alerts = db.check_future_health(con)
146+ assert any(a.startswith("laval") and "0 événement futur" in a
147+ for a in alerts)
148+
149+
150+def test_dedup_key_seances_distinctes():
151+ """Même titre/ville/jour mais heures différentes = séances distinctes
152+ (clés différentes) ; même heure = fusion inter-sources conservée."""
153+ a = Event(source="lepointdevente", external_id="1", url="https://x/1",
154+ title="Parcours découverte", city="Québec",
155+ start_date="2026-09-03", start_time="09:30").finalize()
156+ b = Event(source="lepointdevente", external_id="2", url="https://x/2",
157+ title="Parcours découverte", city="Québec",
158+ start_date="2026-09-03", start_time="14:30").finalize()
159+ c = Event(source="lavitrine", external_id="3", url="https://x/3",
160+ title="Parcours découverte", city="Québec",
161+ start_date="2026-09-03", start_time="09:30").finalize()
162+ assert a.dedup_key() != b.dedup_key() # séances préservées
163+ assert a.dedup_key() == c.dedup_key() # fusion inter-sources conservée
80 164
81 165
82 def test_dedup_key_cross_source(): 166 def test_dedup_key_cross_source():
modified tests/test_normalize.py +25 −0
@@ -105,3 +105,28 @@ def test_event_finalize_and_dedup():
105 assert a.is_free is True 105 assert a.is_free is True
106 assert a.end_date == "2026-07-01" 106 assert a.end_date == "2026-07-01"
107 assert a.dedup_key() == b.dedup_key() 107 assert a.dedup_key() == b.dedup_key()
108+
109+
110+def test_canonical_city_mamh():
111+ """Phase 2 : les graphies sans accents des sources sont ramenées au nom
112+ officiel du Répertoire MAMH (facettes ville unifiées)."""
113+ from sortika.normalize import canonical_city
114+ assert canonical_city("Montreal") == "Montréal"
115+ assert canonical_city("Quebec") == "Québec"
116+ assert canonical_city("Levis") == "Lévis"
117+ assert canonical_city("Trois-Rivieres") == "Trois-Rivières"
118+ assert canonical_city("MONTRÉAL") == "Montréal"
119+ assert canonical_city("Toronto") == "Toronto" # hors répertoire : intact
120+ assert clean_city("Montreal, Québec") == "Montréal"
121+
122+
123+def test_normalize_status():
124+ from sortika.schema import normalize_status
125+ assert normalize_status("cancelled") == "cancelled"
126+ assert normalize_status("ANNULÉ") == "cancelled"
127+ assert normalize_status("sold_out") == "soldout"
128+ assert normalize_status("postponed") == "postponed"
129+ assert normalize_status("rescheduled") == "postponed"
130+ assert normalize_status("buy_now") == ""
131+ assert normalize_status("") == ""
132+ assert normalize_status(None) == ""
108 133
Menu

Theme