self.time_limit:
63 | raise CircuitBreakerTooLongException(self.name)
64 |
65 | with self.policies.transact():
66 | self.policies.append("ON")
67 | if len(self.policies) < self.trigger:
68 | self.policies.append("ON")
69 | return value
70 |
71 | except CircuitBreakerTooLongException:
72 | self.count_error()
73 | return value
74 | except Exception as e:
75 | self.count_error()
76 | raise e
77 |
78 | def breaker_enabled(self, enabled: bool):
79 | self.enabled = enabled
80 |
81 | def get_policy(self):
82 | start_time = time.time()
83 | while (time.time() - start_time) < self.time_limit:
84 | with self.policies.transact():
85 | try:
86 | return self.policies.popleft()
87 | except IndexError:
88 | time.sleep(0.200)
89 | return "OFF"
90 |
91 | def count_error(self):
92 | with self.policies.transact():
93 | if len(self.policies) == 0:
94 | self.policies += ["OFF"] * self.release
95 | self.policies += ["ON"] * self.trigger
96 |
97 | def call_off(self, *args, **kwargs):
98 | if self.off_func is not None:
99 | return self.off_func(*args, **kwargs)
100 | else:
101 | raise CircuitBreakerOffException(self.name)
102 |
103 |
104 | class CircuitBreakerOffException(RuntimeError):
105 | def __init__(self, name):
106 | msg = f"CircuitBreaker '{name}' is currently off"
107 | super().__init__(self, msg)
108 | self.message = msg
109 | self.name = name
110 |
111 |
112 | class CircuitBreakerTooLongException(RuntimeError):
113 | def __init__(self, name):
114 | msg = f"CircuitBreaker '{name}' execution took too long"
115 | super().__init__(self, msg)
116 | self.message = msg
117 | self.name = name
118 |
--------------------------------------------------------------------------------
/scraper/creneaux/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/creneaux/__init__.py
--------------------------------------------------------------------------------
/scraper/creneaux/creneau.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from enum import Enum
3 | from pytz import timezone as Timezone
4 | from datetime import datetime
5 | from typing import Optional, List
6 | from scraper.pattern.center_location import CenterLocation
7 | from scraper.pattern.scraper_request import ScraperRequest
8 | from scraper.pattern.vaccine import Vaccine
9 |
10 |
11 | class Plateforme(str, Enum):
12 | DOCTOLIB = "Doctolib"
13 | MAIIA = "Maiia"
14 | ORDOCLIC = "Ordoclic"
15 | KELDOC = "Keldoc"
16 | MAPHARMA = "Mapharma"
17 | AVECMONDOC = "AvecMonDoc"
18 | MESOIGNER = "mesoigner"
19 | BIMEDOC = "Bimedoc"
20 | VALWIN = "Valwin"
21 |
22 |
23 | @dataclass
24 | class Lieu:
25 | departement: str
26 | nom: str
27 | url: str
28 | lieu_type: str
29 | internal_id: str
30 | location: Optional[CenterLocation] = None
31 | metadata: Optional[dict] = None
32 | plateforme: Optional[Plateforme] = None
33 | atlas_gid: Optional[int] = None
34 |
35 |
36 | @dataclass
37 | class Creneau:
38 | horaire: datetime
39 | lieu: Lieu
40 | reservation_url: str
41 | dose: list = None
42 | timezone: Timezone = Timezone("Europe/Paris")
43 | type_vaccin: Optional[List[Vaccine]] = None
44 |
45 | disponible: bool = True
46 |
47 |
48 | @dataclass
49 | class PasDeCreneau:
50 | lieu: Lieu
51 | phone_only: bool = False
52 | disponible: bool = False
53 | dose: int = None
54 |
--------------------------------------------------------------------------------
/scraper/doctolib/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/doctolib/__init__.py
--------------------------------------------------------------------------------
/scraper/doctolib/doctolib_filters.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from scraper.pattern.scraper_result import DRUG_STORE, GENERAL_PRACTITIONER, VACCINATION_CENTER
4 | from utils.vmd_config import get_conf_platform
5 |
6 | DOCTOLIB_CONF = get_conf_platform("doctolib")
7 | DOCTOLIB_FILTERS = DOCTOLIB_CONF.get("filters", {})
8 |
9 | DOCTOLIB_CATEGORY = DOCTOLIB_FILTERS.get("appointment_category", [])
10 | DOCTOLIB_CATEGORY = [c.lower().strip() for c in DOCTOLIB_CATEGORY]
11 |
12 |
13 | def is_category_relevant(category):
14 | if not category:
15 | return False
16 |
17 | category = category.lower().strip()
18 | category = re.sub(" +", " ", category)
19 | for allowed_categories in DOCTOLIB_CATEGORY:
20 | if allowed_categories in category:
21 | return True
22 | # Weird centers. But it's vaccination related COVID-19.
23 | if category == "vaccination":
24 | return True
25 | return False
26 |
27 |
28 | # Filter by relevant appointments
29 | def is_appointment_relevant(motive_id):
30 |
31 | vaccination_motives = [int(item) for item in DOCTOLIB_FILTERS["motives"].keys()]
32 | """Tell if an appointment name is related to COVID-19 vaccination
33 |
34 | Example
35 | ----------
36 | >>> is_appointment_relevant(6970)
37 | True
38 | >>> is_appointment_relevant(245617)
39 | False
40 | """
41 | if not motive_id:
42 | return False
43 |
44 | if motive_id in vaccination_motives:
45 | return True
46 |
47 | return False
48 |
49 |
50 | def dose_number(motive_id: int):
51 | if not motive_id:
52 | return None
53 | dose_number = DOCTOLIB_FILTERS["motives"][str(motive_id)]["dose"]
54 | if dose_number:
55 | return dose_number
56 | return None
57 |
58 |
59 | # Parse practitioner type from Doctolib booking data.
60 | def parse_practitioner_type(name, data):
61 | if "pharmacie" in name.lower():
62 | return DRUG_STORE
63 | profile = data.get("profile", {})
64 | specialty = profile.get("speciality", {})
65 | if specialty:
66 | slug = specialty.get("slug", None)
67 | if slug and slug == "medecin-generaliste":
68 | return GENERAL_PRACTITIONER
69 | return VACCINATION_CENTER
70 |
71 |
72 | def is_vaccination_center(center_dict):
73 | """Determine if a center provide COVID19 vaccinations.
74 | See: https://github.com/CovidTrackerFr/vitemadose/issues/271
75 |
76 | Parameters
77 | ----------
78 | center_dict : "Center" dict
79 | Center dict, output by the doctolib_center_scrap.center_from_doctor_dict
80 |
81 | Returns
82 | ----------
83 | bool
84 | True if if we think the center provide COVID19 vaccination
85 |
86 | Example
87 | ----------
88 | >>> center_without_vaccination = {'gid': 'd258630', 'visit_motives_ids': [224512]}
89 | >>> is_vaccination_center(center_without_vaccination)
90 | False
91 | >>> center_with_vaccination = {'gid': 'd257554', 'visit_motives_ids': [6970]}
92 | >>> is_vaccination_center(center_with_vaccination)
93 | True
94 | """
95 | motives = center_dict.get("visit_motives_ids", [])
96 |
97 | # We don't have any motiv
98 | # so this criteria isn't relevant to determine if a center is a vaccination center
99 | # considering it as a vaccination one to prevent mass filtering
100 | # see https://github.com/CovidTrackerFr/vitemadose/issues/271
101 | if len(motives) == 0:
102 | return True
103 |
104 | for motive in motives:
105 | if is_appointment_relevant(motive): # first vaccine motive, it's a vaccination center
106 | return True
107 |
108 | return False # No vaccination motives found
109 |
--------------------------------------------------------------------------------
/scraper/error.py:
--------------------------------------------------------------------------------
1 | class ScrapeError(Exception):
2 | def __init__(self, plateforme="Autre", raison="Erreur de scrapping"):
3 | super().__init__(f"ERREUR DE SCRAPPING ({plateforme}): {raison}")
4 | self.plateforme = plateforme
5 | self.raison = raison
6 |
7 |
8 | class Blocked403(ScrapeError):
9 | def __init__(self, platform, url):
10 | super().__init__(platform, f"Doctolib bloque nos appels: 403 {url}")
11 |
12 |
13 | class RequestError(ScrapeError):
14 | def __init__(self, url, response_code="wrong-url"):
15 | super().__init__("Doctolib", f"Erreur {response_code} lors de l'accès à {url}")
16 | self.blocked = True
17 |
18 |
19 | class DoublonDoctolib(ScrapeError):
20 | def __init__(self, url):
21 | super().__init__(
22 | "Doctolib", f"Le centre est un doublon ou ne propose pas de motif de vaccination sur ce lieu {url}"
23 | )
24 |
--------------------------------------------------------------------------------
/scraper/export/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/export/__init__.py
--------------------------------------------------------------------------------
/scraper/export/export_v2.py:
--------------------------------------------------------------------------------
1 | from utils.vmd_utils import q_iter
2 | from scraper.creneaux.creneau import Creneau
3 | from scraper.export.resource_centres import ResourceParDepartement, ResourceTousDepartements
4 | from scraper.export.resource_creneaux_quotidiens import ResourceCreneauxQuotidiens
5 | from scraper.pattern.tags import CURRENT_TAGS
6 | import os
7 | import json
8 | import logging
9 | from typing import Iterator
10 | from dataclasses import dataclass
11 | import sys
12 | from utils.vmd_config import get_conf_outputs, get_config
13 |
14 | logger = logging.getLogger("scraper")
15 |
16 |
17 | class JSONExporter:
18 | def __init__(self, departements=None, outpath_format="data/output/{}.json"):
19 | self.outpath_format = outpath_format
20 | departements = departements if departements else Departement.all()
21 | resources_departements = {
22 | departement.code: ResourceParDepartement(departement.code) for departement in departements
23 | }
24 | resources_creneaux_quotidiens = {
25 | f"{departement.code}/creneaux-quotidiens": ResourceCreneauxQuotidiens(departement.code, tags=CURRENT_TAGS)
26 | for departement in departements
27 | }
28 | self.resources = {
29 | "info_centres": ResourceTousDepartements(),
30 | **resources_departements,
31 | **resources_creneaux_quotidiens,
32 | }
33 |
34 | def export(self, creneaux: Iterator[Creneau]):
35 | count = 0
36 | for creneau in creneaux:
37 | count += 1
38 |
39 | for resource in self.resources.values():
40 | resource.on_creneau(creneau)
41 |
42 | lieux_avec_dispo = len(self.resources["info_centres"].centres_disponibles)
43 | lieux_sans_dispo = len(self.resources["info_centres"].centres_indisponibles)
44 | lieux_bloques_mais_dispo = len(self.resources["info_centres"].centres_bloques_mais_disponibles)
45 |
46 | if lieux_avec_dispo == 0:
47 | logger.error(
48 | "Aucune disponibilité n'a été trouvée sur aucun centre, c'est bizarre, alors c'est probablement une erreur"
49 | )
50 | exit(code=1)
51 |
52 | logger.info(
53 | f"{lieux_avec_dispo} centres ont des disponibilités sur {lieux_avec_dispo+lieux_sans_dispo} centre scannés (+{lieux_bloques_mais_dispo} bloqués)"
54 | )
55 | logger.info(f"{count} créneaux dans {lieux_avec_dispo} centres")
56 | print("\n")
57 | if lieux_bloques_mais_dispo > 0:
58 | logger.info(f"{lieux_bloques_mais_dispo} centres sont bloqués mais ont des disponibilités : ")
59 | for centre_bloque in self.resources["info_centres"].centres_bloques_mais_disponibles:
60 | logger.info(f"Le centre {centre_bloque} est bloqué mais a des disponibilités.")
61 |
62 | for key, resource in self.resources.items():
63 | outfile_path = self.outpath_format.format(key)
64 | os.makedirs(os.path.dirname(outfile_path), exist_ok=True)
65 | with open(outfile_path, "w") as outfile:
66 | logger.debug(f"Writing file {outfile_path}")
67 | json.dump(resource.asdict(), outfile, indent=2)
68 |
69 | with open(get_conf_outputs().get("data_gouv"), "w") as outfile:
70 | logger.debug(f'Writing file {get_conf_outputs().get("data_gouv")}')
71 | json.dump(self.resources["info_centres"].opendata, outfile, indent=2)
72 |
73 |
74 | @dataclass
75 | class Departement:
76 | code_departement: str
77 | nom_departement: str
78 | code_region: int
79 | nom_region: str
80 |
81 | @property
82 | def code(self) -> str:
83 | return self.code_departement
84 |
85 | @property
86 | def nom(self) -> str:
87 | return self.nom_departement
88 |
89 | @classmethod
90 | def all(cls):
91 | dir_path = os.path.dirname(os.path.realpath(__file__))
92 | json_source_path = os.path.join(dir_path, "../../data/output/departements.json")
93 | with open(json_source_path, "r") as source:
94 | departements = json.load(source)
95 | return [Departement(**dep) for dep in departements]
96 |
--------------------------------------------------------------------------------
/scraper/export/resource.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from datetime import datetime
3 | from typing import Iterator, Union
4 | from scraper.creneaux.creneau import Creneau, Lieu, Plateforme, PasDeCreneau
5 |
6 |
7 | class Resource(ABC):
8 | @abstractmethod
9 | def on_creneau(self, creneau: Union[Creneau, PasDeCreneau]):
10 | return None
11 |
12 | @abstractmethod
13 | def asdict(self):
14 | return {}
15 |
16 | @classmethod
17 | def from_creneaux(cls, creneaux: Iterator[Union[Creneau, PasDeCreneau]], *args, **kwargs):
18 | """
19 | On retourne un iterateur qui contient un seul et unique ResourceParDepartement pour pouvoir découpler
20 | l'invocation de l'execution car l'execution ne se lance alors qu'à l'appel
21 | de `next(ResourceParDepartement.from_creneaux())`
22 | """
23 | resource = cls(*args, **kwargs)
24 | for creneau in creneaux:
25 | resource.on_creneau(creneau)
26 | yield resource
27 |
--------------------------------------------------------------------------------
/scraper/export/resource_creneaux_quotidiens.py:
--------------------------------------------------------------------------------
1 | import dateutil
2 | from dateutil.tz import gettz
3 | from datetime import datetime, timedelta
4 | from typing import Iterator, Union
5 | from .resource import Resource
6 | from scraper.creneaux.creneau import Creneau, Lieu, Plateforme, PasDeCreneau
7 | from utils.vmd_config import get_config
8 |
9 | DEFAULT_NEXT_DAYS = get_config().get("scrape_on_n_days", 7)
10 |
11 | DEFAULT_TAGS = {"all": [lambda creneau: True]}
12 |
13 |
14 | class ResourceCreneauxQuotidiens(Resource):
15 | def __init__(self, departement, next_days=DEFAULT_NEXT_DAYS, now=datetime.now, tags=DEFAULT_TAGS):
16 | super().__init__()
17 | self.departement = departement
18 | self.now = now
19 | self.next_days = next_days
20 | today = now(tz=gettz("Europe/Paris"))
21 | self.dates = {}
22 | for days_from_now in range(0, next_days + 1):
23 | day = today + timedelta(days=days_from_now)
24 | date = as_date(day)
25 | self.dates[date] = ResourceCreneauxParDate(date=date, tags=tags)
26 |
27 | def on_creneau(self, creneau: Union[Creneau, PasDeCreneau]):
28 | if creneau.disponible and creneau.lieu.departement == self.departement:
29 | date = as_date(creneau.horaire)
30 | if date in self.dates:
31 | self.dates[date].on_creneau(creneau)
32 |
33 | def asdict(self):
34 | return {
35 | "departement": self.departement,
36 | "creneaux_quotidiens": [
37 | date.asdict() for date in self.dates.values() if isinstance(date, ResourceCreneauxParDate)
38 | ],
39 | }
40 |
41 |
42 | class ResourceCreneauxParDate(Resource):
43 | def __init__(self, date: str, tags=DEFAULT_TAGS):
44 | super().__init__()
45 | self.date = date
46 | self.total = 0
47 | self.tags = tags
48 | self.lieux = {}
49 |
50 | def on_creneau(self, creneau: Union[Creneau, PasDeCreneau]):
51 | if creneau.disponible and as_date(creneau.horaire) == self.date:
52 | self.total += 1
53 | if not creneau.lieu.internal_id in self.lieux:
54 | self.lieux[creneau.lieu.internal_id] = ResourceCreneauxParLieu(
55 | internal_id=creneau.lieu.internal_id, tags=self.tags
56 | )
57 |
58 | self.lieux[creneau.lieu.internal_id].on_creneau(creneau)
59 |
60 | def asdict(self):
61 | return {
62 | "date": self.date,
63 | "total": self.total,
64 | "creneaux_par_lieu": [lieu.asdict() for lieu in self.lieux.values()],
65 | }
66 |
67 |
68 | class ResourceCreneauxParLieu(Resource):
69 | def __init__(self, internal_id: str, tags=DEFAULT_TAGS):
70 | super().__init__()
71 | self.internal_id = internal_id
72 | self.total = 0
73 | self.tags = tags
74 | self.par_tag = {tag: {"tag": tag, "creneaux": 0} for tag in tags.keys()}
75 |
76 | def on_creneau(self, creneau: Union[Creneau, PasDeCreneau]):
77 | if creneau.disponible and creneau.lieu.internal_id == self.internal_id:
78 | self.total += 1
79 | for tag, qualifies_list in self.tags.items():
80 | for qualifies in qualifies_list:
81 | if qualifies(creneau):
82 | self.par_tag[tag]["creneaux"] += 1
83 |
84 | def asdict(self):
85 | return {"lieu": self.internal_id, "creneaux_par_tag": list(self.par_tag.values())}
86 |
87 |
88 | def as_date(datetime):
89 | return datetime.isoformat()[:10]
90 |
--------------------------------------------------------------------------------
/scraper/keldoc/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/keldoc/__init__.py
--------------------------------------------------------------------------------
/scraper/keldoc/keldoc.py:
--------------------------------------------------------------------------------
1 | import os
2 | import logging
3 |
4 | import httpx
5 | from typing import Dict, Iterator, List, Optional, Tuple, Set
6 | from scraper.keldoc.keldoc_center import KeldocCenter
7 | from scraper.keldoc.keldoc_filters import filter_vaccine_motives
8 | from scraper.pattern.scraper_request import ScraperRequest
9 | from scraper.profiler import Profiling
10 | from utils.vmd_config import get_conf_platform, get_config, get_conf_outputs
11 | from utils.vmd_utils import DummyQueue
12 | from scraper.circuit_breaker import ShortCircuit
13 | from scraper.creneaux.creneau import Creneau, Lieu, Plateforme, PasDeCreneau
14 | import json
15 | import requests
16 | from cachecontrol import CacheControl
17 | from cachecontrol.caches.file_cache import FileCache
18 |
19 |
20 | # PLATFORM MUST BE LOW, PLEASE LET THE "lower()" IN CASE OF BAD INPUT FORMAT.
21 | PLATFORM = "keldoc".lower()
22 |
23 | PLATFORM_CONF = get_conf_platform("keldoc")
24 | PLATFORM_ENABLED = PLATFORM_CONF.get("enabled", False)
25 |
26 | PLATFORM_TIMEOUT = PLATFORM_CONF.get("timeout", 25)
27 |
28 | SCRAPE_ONLY_ATLAS = get_config().get("scrape_only_atlas_centers", False)
29 |
30 | timeout = httpx.Timeout(PLATFORM_TIMEOUT, connect=PLATFORM_TIMEOUT)
31 | # change KELDOC_KILL_SWITCH to True to bypass Keldoc scraping
32 |
33 | KELDOC_HEADERS = {
34 | "User-Agent": os.environ.get("KELDOC_API_KEY", ""),
35 | }
36 | session = httpx.Client(timeout=timeout, headers=KELDOC_HEADERS)
37 | logger = logging.getLogger("scraper")
38 |
39 | # Allow 10 bad runs of keldoc_slot before giving up for the 200 next tries
40 | #@ShortCircuit("keldoc_slot", trigger=10, release=200, time_limit=40.0)
41 | #@Profiling.measure("keldoc_slot")
42 | def fetch_slots(request: ScraperRequest, creneau_q=DummyQueue()):
43 | if "keldoc.com" in request.url:
44 | logger.debug(f"Fixing wrong hostname in request: {request.url}")
45 | request.url = request.url.replace("keldoc.com", "vaccination-covid.keldoc.com")
46 | if not PLATFORM_ENABLED:
47 | return None
48 | center = KeldocCenter(request, client=session, creneau_q=creneau_q)
49 | center.vaccine_motives = filter_vaccine_motives(center.appointment_motives)
50 |
51 | center.lieu = Lieu(
52 | plateforme=Plateforme[PLATFORM.upper()],
53 | url=request.url,
54 | location=request.center_info.location,
55 | nom=request.center_info.nom,
56 | internal_id=f"keldoc{request.internal_id}",
57 | departement=request.center_info.departement,
58 | lieu_type=request.practitioner_type,
59 | metadata=request.center_info.metadata,
60 | atlas_gid=request.atlas_gid,
61 | )
62 |
63 | # Find the first availability
64 | date, count = center.find_first_availability(request.get_start_date())
65 | if not date and center.lieu:
66 | if center.lieu:
67 | center.found_creneau(PasDeCreneau(lieu=center.lieu, phone_only=request.appointment_by_phone_only))
68 | request.update_appointment_count(0)
69 | return None
70 |
71 | request.update_appointment_count(count)
72 | return date.strftime("%Y-%m-%dT%H:%M:%S.%f%z")
73 |
74 |
75 | def center_iterator(client=None) -> Iterator[Dict]:
76 | if not PLATFORM_ENABLED:
77 | logger.warning(f"{PLATFORM.capitalize()} scrap is disabled in configuration file.")
78 | return []
79 |
80 | if SCRAPE_ONLY_ATLAS:
81 | logger.warning(f"{PLATFORM.capitalize()} will only scrape ATLASSANTE centers.")
82 |
83 | session = CacheControl(requests.Session(), cache=FileCache("./cache"))
84 |
85 | if client:
86 | session = client
87 | try:
88 | url = f'{get_config().get("base_urls").get("github_public_path")}{get_conf_outputs().get("centers_json_path").format(PLATFORM)}'
89 | response = session.get(url)
90 | # Si on ne vient pas des tests unitaires
91 | if not client:
92 | if response.from_cache:
93 | logger.info(f"Liste des centres pour {PLATFORM} vient du cache")
94 | else:
95 | logger.info(f"Liste des centres pour {PLATFORM} est une vraie requête")
96 |
97 | data = response.json()
98 |
99 | if SCRAPE_ONLY_ATLAS:
100 | data = [center for center in data if center["atlas_gid"]]
101 |
102 | logger.info(f"Found {len(data)} {PLATFORM.capitalize()} centers (external scraper).")
103 |
104 | for center in data:
105 | yield center
106 |
107 | except Exception as e:
108 | logger.warning(f"Unable to scrape {PLATFORM} centers: {e}")
109 |
--------------------------------------------------------------------------------
/scraper/keldoc/keldoc_routes.py:
--------------------------------------------------------------------------------
1 | from utils.vmd_config import get_conf_platform
2 |
3 | KELDOC_CONF = get_conf_platform("keldoc")
4 | KELDOC_API = KELDOC_CONF.get("api")
5 |
6 | # Center info route
7 | API_KELDOC_CENTER = KELDOC_API.get("booking")
8 |
9 | # Motive list route
10 | API_KELDOC_MOTIVES = KELDOC_API.get("motives")
11 |
12 | # Cabinet list route
13 | API_KELDOC_CABINETS = KELDOC_API.get("cabinets")
14 |
15 | # Calendar details route
16 | API_KELDOC_CALENDAR = KELDOC_API.get("slots")
17 |
18 | API_SPECIALITY_IDS = KELDOC_CONF.get("filters").get("vaccination_speciality_ids")
19 |
--------------------------------------------------------------------------------
/scraper/maiia/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/maiia/__init__.py
--------------------------------------------------------------------------------
/scraper/maiia/maiia_utils.py:
--------------------------------------------------------------------------------
1 | import httpx
2 | import json
3 | import logging
4 | import os
5 |
6 | from scraper.pattern.scraper_request import ScraperRequest
7 | from utils.vmd_config import get_conf_platform
8 |
9 | MAIIA_CONF = get_conf_platform("maiia")
10 | MAIIA_SCRAPER = MAIIA_CONF.get("center_scraper", {})
11 | MAIIA_HEADERS = {
12 | "User-Agent": os.environ.get("MAIIA_API_KEY", ""),
13 | }
14 |
15 | timeout = httpx.Timeout(MAIIA_CONF.get("timeout", 25), connect=MAIIA_CONF.get("timeout", 25))
16 | DEFAULT_CLIENT = httpx.Client(timeout=timeout, headers=MAIIA_HEADERS)
17 | logger = logging.getLogger("scraper")
18 |
19 | MAIIA_LIMIT = MAIIA_SCRAPER.get("centers_per_page")
20 |
21 |
22 | def get_paged(
23 | url: str,
24 | limit: MAIIA_LIMIT,
25 | client: httpx.Client = DEFAULT_CLIENT,
26 | request: ScraperRequest = None,
27 | request_type: str = None,
28 | ) -> dict:
29 | result = dict()
30 | result["items"] = []
31 | page = 0
32 | while True:
33 | base_url = f"{url}&limit={limit}&page={page}&size={limit}"
34 | if request:
35 | request.increase_request_count(request_type)
36 | try:
37 | r = client.get(base_url, headers=MAIIA_HEADERS)
38 | r.raise_for_status()
39 | except httpx.HTTPStatusError as hex:
40 | logger.warning(f"{base_url} returned error {hex.response.status_code}")
41 | break
42 | try:
43 | payload = r.json()
44 | except json.decoder.JSONDecodeError as jde:
45 | logger.warning(f"{base_url} raised {jde}")
46 | break
47 | result["total"] = payload["total"]
48 | if not payload["items"]:
49 | break
50 | result["items"].extend(payload["items"])
51 | if len(payload["items"]) < limit:
52 | break
53 | page += 1
54 | return result
55 |
--------------------------------------------------------------------------------
/scraper/main.py:
--------------------------------------------------------------------------------
1 | import argparse
2 |
3 | from scraper.scraper import scrape, scrape_debug
4 |
5 |
6 | def main(): # pragma: no cover
7 | parser = argparse.ArgumentParser()
8 | parser.add_argument("--platform", "-p", help="scrape platform. (eg: doctolib,keldoc or all)")
9 | parser.add_argument("--url", "-u", action="append", help="scrape one url, can be repeated")
10 | parser.add_argument("--url-file", type=argparse.FileType("r"), help="scrape urls listed in file (one per line)")
11 | args = parser.parse_args()
12 |
13 | if args.url_file:
14 | args.url = [line.rstrip() for line in args.url_file]
15 | if args.url:
16 | scrape_debug(args.url)
17 | return
18 | platforms = []
19 | if args.platform and args.platform != "all":
20 | platforms = args.platform.split(",")
21 | scrape(platforms=platforms)
22 |
23 |
24 | if __name__ == "__main__": # pragma: no cover
25 | main()
26 |
--------------------------------------------------------------------------------
/scraper/mapharma/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/mapharma/__init__.py
--------------------------------------------------------------------------------
/scraper/mesoigner/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/mesoigner/__init__.py
--------------------------------------------------------------------------------
/scraper/pattern/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/pattern/__init__.py
--------------------------------------------------------------------------------
/scraper/pattern/center_location.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from dataclasses import asdict
4 | from typing import Optional
5 |
6 | from pydantic.dataclasses import dataclass
7 |
8 | from utils.vmd_utils import departementUtils
9 | from utils.vmd_logger import get_logger
10 |
11 | logger = get_logger()
12 |
13 |
14 | @dataclass
15 | class CenterLocation:
16 | longitude: float
17 | latitude: float
18 | city: Optional[str] = None
19 | cp: Optional[str] = None
20 |
21 | # TODO: Use `asdict()` directly, default is not clear.
22 | def default(self):
23 | return asdict(self)
24 |
25 | @classmethod
26 | def from_csv_data(cls, data: dict) -> Optional[CenterLocation]:
27 | long = data.get("long_coor1")
28 | lat = data.get("lat_coor1")
29 | city = data.get("com_nom")
30 | cp = data.get("com_cp")
31 |
32 | if long and lat:
33 | if address := data.get("address"):
34 | if not city:
35 | city = departementUtils.get_city(address)
36 | if not cp:
37 | cp = departementUtils.get_cp(address)
38 | try:
39 | return CenterLocation(long, lat, city, cp)
40 | except Exception as e:
41 | logger.warning("Failed to parse CenterLocation from {}".format(data))
42 | logger.warning(e)
43 | return
44 |
45 |
46 | convert_csv_data_to_location = CenterLocation.from_csv_data
47 |
--------------------------------------------------------------------------------
/scraper/pattern/scraper_request.py:
--------------------------------------------------------------------------------
1 | from typing import List, Optional
2 |
3 |
4 | class ScraperRequest:
5 | def __init__(
6 | self,
7 | url: str,
8 | start_date: str,
9 | center_info=None,
10 | practitioner_type=None,
11 | internal_id=None,
12 | input_data=None,
13 | atlas_gid=None,
14 | ):
15 | self.url = url
16 | self.start_date = start_date
17 | self.center_info = center_info
18 | self.internal_id = internal_id
19 | self.practitioner_type = practitioner_type
20 | self.appointment_count = 0
21 | self.vaccine_type = None
22 | self.appointment_by_phone_only = False
23 | self.requests = None
24 | self.input_data = input_data
25 | self.atlas_gid = atlas_gid
26 |
27 | def update_internal_id(self, internal_id: str) -> str:
28 | self.internal_id = internal_id
29 | return self.internal_id
30 |
31 | def update_practitioner_type(self, practitioner_type: str) -> str:
32 | self.practitioner_type = practitioner_type
33 | return self.practitioner_type
34 |
35 | def update_appointment_count(self, appointment_count: int) -> int:
36 | self.appointment_count = appointment_count
37 | return self.appointment_count
38 |
39 | def increase_request_count(self, request_type: str) -> int:
40 | if self.requests is None:
41 | self.requests = {}
42 | request_type = request_type or "unknown"
43 | if request_type not in self.requests:
44 | self.requests[request_type] = 1
45 | else:
46 | self.requests[request_type] += 1
47 | return self.requests[request_type]
48 |
49 | def add_vaccine_type(self, vaccine_name: Optional[str]):
50 | # Temp fix due to iOS app issues with empty list
51 | if self.vaccine_type is None:
52 | self.vaccine_type = []
53 | if vaccine_name and vaccine_name not in self.vaccine_type:
54 | self.vaccine_type.append(vaccine_name)
55 |
56 | def get_url(self) -> str:
57 | return self.url
58 |
59 | def get_start_date(self) -> str:
60 | return self.start_date
61 |
62 | def set_appointments_only_by_phone(self, only_by_phone: bool):
63 | self.appointment_by_phone_only = only_by_phone
64 |
--------------------------------------------------------------------------------
/scraper/pattern/scraper_result.py:
--------------------------------------------------------------------------------
1 | from scraper.pattern.scraper_request import ScraperRequest
2 |
3 |
4 | # Practitioner type enum
5 | GENERAL_PRACTITIONER = "general-practitioner"
6 | VACCINATION_CENTER = "vaccination-center"
7 | DRUG_STORE = "drugstore"
8 |
9 |
10 | class ScraperResult:
11 | def __init__(self, request: ScraperRequest, platform, next_availability):
12 | self.request = request
13 | self.platform = platform
14 | self.next_availability = next_availability
15 |
16 | def default(self):
17 | return self.__dict__
18 |
--------------------------------------------------------------------------------
/scraper/pattern/tags.py:
--------------------------------------------------------------------------------
1 | from scraper.creneaux.creneau import Creneau
2 | from scraper.pattern.vaccine import Vaccine
3 |
4 |
5 | def tag_all(creneau: Creneau):
6 | return True
7 |
8 |
9 | def first_dose(creneau: Creneau):
10 | if creneau.dose:
11 | if "1" in creneau.dose or 1 in creneau.dose:
12 | return True
13 |
14 |
15 | def second_dose(creneau: Creneau):
16 | if creneau.dose:
17 | if "2" in creneau.dose or 2 in creneau.dose:
18 | return True
19 |
20 |
21 | def third_dose(creneau: Creneau):
22 | if creneau.dose:
23 | if "3" in creneau.dose or 3 in creneau.dose:
24 | return True
25 |
26 |
27 |
28 | def kid_first_dose(creneau: Creneau):
29 | if creneau.dose:
30 | if "1_kid" in creneau.dose:
31 | return True
32 |
33 |
34 | def unknown_dose(creneau: Creneau):
35 | if not creneau.dose:
36 | return True
37 | if len(creneau.dose) == 0:
38 | return True
39 |
40 |
41 | CURRENT_TAGS = {
42 | "all": [tag_all],
43 | "first_or_second_dose": [first_dose, second_dose],
44 | "kids_first_dose": [kid_first_dose],
45 | "third_dose": [third_dose],
46 | "unknown_dose": [unknown_dose],
47 | }
48 |
--------------------------------------------------------------------------------
/scraper/pattern/vaccine.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 | from typing import Optional
3 |
4 | from utils.vmd_config import get_config
5 |
6 | from scraper.doctolib.doctolib_filters import DOCTOLIB_FILTERS
7 |
8 | VACCINE_CONF = get_config().get("vaccines", {})
9 |
10 | DOCTOLIB_APPOINTMENT_MOTIVES = DOCTOLIB_FILTERS["motives"]
11 |
12 |
13 | class Vaccine(str, Enum):
14 | PFIZER = "Pfizer-BioNTech"
15 | MODERNA = "Moderna"
16 | ASTRAZENECA = "AstraZeneca"
17 | JANSSEN = "Janssen"
18 | ARNM = "ARNm"
19 |
20 |
21 | VACCINES_NAMES = {
22 | Vaccine.PFIZER: VACCINE_CONF.get(Vaccine.PFIZER, []),
23 | Vaccine.MODERNA: VACCINE_CONF.get(Vaccine.MODERNA, []),
24 | Vaccine.ARNM: VACCINE_CONF.get(Vaccine.ARNM, []),
25 | Vaccine.ASTRAZENECA: VACCINE_CONF.get(Vaccine.ASTRAZENECA, []),
26 | Vaccine.JANSSEN: VACCINE_CONF.get(Vaccine.JANSSEN, []),
27 | }
28 |
29 |
30 | def get_doctolib_vaccine_name(visit_motive_id: int, fallback: Optional[Vaccine] = None) -> Optional[Vaccine]:
31 | if not visit_motive_id:
32 | return fallback
33 | name = DOCTOLIB_APPOINTMENT_MOTIVES[str(visit_motive_id)]["vaccine"]
34 | return name
35 |
36 |
37 | def get_vaccine_name(name: Optional[str], fallback: Optional[Vaccine] = None) -> Optional[Vaccine]:
38 | if not name:
39 | return fallback
40 | name = name.lower().strip()
41 | if "contre indications" in name:
42 | return fallback
43 | for vaccine, vaccine_names in VACCINES_NAMES.items():
44 | for vaccine_name in vaccine_names:
45 | if vaccine_name in name:
46 | if vaccine == Vaccine.ASTRAZENECA:
47 | return get_vaccine_astrazeneca_minus_55_edgecase(name)
48 | return vaccine
49 | return fallback
50 |
51 |
52 | def get_vaccine_astrazeneca_minus_55_edgecase(name: str) -> Vaccine:
53 | has_minus = "-" in name or "–" in name or "–" in name or "moins" in name
54 | if has_minus and "55" in name and "suite" in name:
55 | return Vaccine.ARNM
56 | return Vaccine.ASTRAZENECA
57 |
--------------------------------------------------------------------------------
/scraper/valwin/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/valwin/__init__.py
--------------------------------------------------------------------------------
/scraper/valwin/valwin_center_scrap.py:
--------------------------------------------------------------------------------
1 | import httpx
2 | from utils.vmd_logger import get_logger
3 | from utils.vmd_config import get_conf_platform
4 | from utils.vmd_utils import departementUtils, format_phone_number
5 | import json
6 | import os
7 |
8 | PLATFORM = "Valwin"
9 |
10 | PLATFORM_HEADERS = {}
11 |
12 | PLATFORM_CONF = get_conf_platform(PLATFORM)
13 | PLATFORM_ENABLED = PLATFORM_CONF.get("enabled", False)
14 |
15 | SCRAPER_CONF = PLATFORM_CONF.get("center_scraper", {})
16 | CENTER_LIST_URL = PLATFORM_CONF.get("api", {}).get("center_list", {})
17 |
18 | DEFAULT_CLIENT = httpx.Client()
19 |
20 | logger = get_logger()
21 |
22 |
23 | def scrap_centers():
24 | if not PLATFORM_ENABLED:
25 | return None
26 |
27 | logger.info(f"[{PLATFORM.lower().capitalize()} centers] Parsing centers from API")
28 | try:
29 | r = DEFAULT_CLIENT.get(
30 | CENTER_LIST_URL,
31 | headers=PLATFORM_HEADERS,
32 | )
33 | api_centers = r.json()
34 |
35 | if r.status_code != 200:
36 | logger.error(f"Can't access API - {r.status_code} => {json.loads(r.text)['message']}")
37 | return None
38 |
39 | except:
40 | logger.error(f"Can't access API")
41 | return None
42 |
43 | return api_centers
44 |
45 |
46 | def get_coordinates(center):
47 | longitude = center["geoTag"]["longitude"]
48 | latitude = center["geoTag"]["latitude"]
49 | if longitude:
50 | longitude = float(longitude)
51 | if latitude:
52 | latitude = float(latitude)
53 | return longitude, latitude
54 |
55 |
56 | def set_center_type(center_type: str):
57 | center_types = PLATFORM_CONF.get("center_types", {})
58 | center_type_format = [center_types[i] for i in center_types if i in center_type]
59 | return center_type_format[0]
60 |
61 |
62 | def parse_platform_business_hours(place: dict):
63 | # Opening hours
64 | business_hours = dict()
65 | if not place["opening_hours"]:
66 | return None
67 |
68 | for opening_hour in place["opening_hours"]:
69 | format_hours = ""
70 | key_name = SCRAPER_CONF["business_days"][opening_hour["day"] - 1]
71 | if not opening_hour["ranges"] or len(opening_hour["ranges"]) == 0:
72 | business_hours[key_name] = None
73 | continue
74 | for range in opening_hour["ranges"]:
75 | if len(format_hours) > 0:
76 | format_hours += ", "
77 | format_hours += f"{range[0]}-{range[1]}"
78 | business_hours[key_name] = format_hours
79 | return business_hours
80 |
81 |
82 | def parse_platform_centers():
83 |
84 | unique_centers = []
85 | centers_list = scrap_centers()
86 | useless_keys = ["id", "hasAvailableSlot", "geoTag", "linkToAllSlots", "geoTag", "name", "websiteUrl"]
87 |
88 | if centers_list is None:
89 | return None
90 |
91 | for centre_name, centre in centers_list.items():
92 | logger.info(f'[Valwin] Found Center {centre["name"]} - {centre["address"]["zipCode"]}')
93 |
94 | if centre["websiteUrl"] not in [unique_center["rdv_site_web"] for unique_center in unique_centers]:
95 | centre["gid"] = centre["id"]
96 | centre["rdv_site_web"] = centre["websiteUrl"]
97 | centre["nom"] = centre["name"]
98 | centre["com_insee"] = departementUtils.cp_to_insee(centre["address"]["zipCode"])
99 | long_coor1, lat_coor1 = get_coordinates(centre)
100 | address = f'{centre["address"]["street"]}, {centre["address"]["zipCode"]} {centre["address"]["city"]}'
101 | centre["address"] = address
102 | centre["long_coor1"] = long_coor1
103 | centre["lat_coor1"] = lat_coor1
104 | centre["type"] = set_center_type("pharmacie")
105 | centre["platform_is"] = PLATFORM
106 |
107 | [centre.pop(key) for key in list(centre.keys()) if key in useless_keys]
108 | unique_centers.append(centre)
109 |
110 | return unique_centers
111 |
112 |
113 | if __name__ == "__main__": # pragma: no cover
114 | if PLATFORM_ENABLED:
115 | centers = parse_platform_centers()
116 | path_out = SCRAPER_CONF.get("result_path", False)
117 | if not path_out:
118 | logger.error(f"Valwin - No result_path in config file.")
119 | exit(1)
120 |
121 | if not centers:
122 | exit(1)
123 |
124 | logger.info(f"Found {len(centers)} centers on Valwin")
125 | if len(centers) < SCRAPER_CONF.get("minimum_results", 0):
126 | logger.error(f"[NOT SAVING RESULTS]{len(centers)} does not seem like enough Valwin centers")
127 | else:
128 | logger.info(f"> Writing them on {path_out}")
129 | with open(path_out, "w") as f:
130 | f.write(json.dumps(centers, indent=2))
131 | else:
132 | logger.error(f"Valwin scraper is disabled in configuration file.")
133 | exit(1)
134 |
--------------------------------------------------------------------------------
/scripts/contributors:
--------------------------------------------------------------------------------
1 | #!/bin/sh -e
2 |
3 | BIN="venv/bin/"
4 |
5 | ${BIN}python contributors.py $@
6 |
7 |
--------------------------------------------------------------------------------
/scripts/coverage:
--------------------------------------------------------------------------------
1 | #!/bin/sh -e
2 |
3 | BIN="venv/bin/"
4 |
5 | set -x
6 |
7 | ${BIN}coverage report --show-missing --skip-covered --fail-under=80
8 |
--------------------------------------------------------------------------------
/scripts/create-index.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -e
4 |
5 | cd $1
6 |
7 | find -type f -name '*.json' \
8 | | cut -c3- \
9 | | sort \
10 | | xargs du -h \
11 | | awk 'BEGIN { print "Ressources disponibles
\n" }' \
14 | > index.html
15 |
--------------------------------------------------------------------------------
/scripts/install:
--------------------------------------------------------------------------------
1 | #!/bin/sh -e
2 |
3 | PYTHON="python3.8"
4 | VENV="venv"
5 | BIN="$VENV/bin/"
6 |
7 | set -x
8 |
9 | if [ ! -d $VENV ]; then
10 | $PYTHON -m venv $VENV
11 | fi
12 |
13 | ${BIN}pip install -U pip wheel
14 | ${BIN}pip install -e .
15 |
--------------------------------------------------------------------------------
/scripts/scrape:
--------------------------------------------------------------------------------
1 | #!/bin/sh -e
2 |
3 | BIN="venv/bin/"
4 |
5 | ${BIN}python scrape.py $@
6 |
--------------------------------------------------------------------------------
/scripts/test:
--------------------------------------------------------------------------------
1 | #!/bin/sh -e
2 |
3 | BIN="venv/bin/"
4 |
5 | set -x
6 |
7 | ${BIN}coverage run -m pytest --ignore tests/
8 |
9 | scripts/coverage
10 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [tool:pytest]
2 | addopts = scraper/ tests/ --doctest-modules
3 |
4 | [coverage:run]
5 | omit = venv/*
6 | scraper/profiler.py
7 | # not used for now
8 | include = scraper/*, tests/*
9 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 |
3 | setup(
4 | name="vitemadose",
5 | version="0.0.1",
6 | packages=["scraper"],
7 | install_requires=[
8 | "pytz==2021.1",
9 | "httpx==0.17.1",
10 | "requests[socks]==2.25.1",
11 | "pytest==6.2.2",
12 | "beautifulsoup4==4.9.3",
13 | "coverage==5.5",
14 | "terminaltables==3.1.0",
15 | "python-dateutil==2.8.1",
16 | "coverage-badge==1.0.1",
17 | "unidecode==1.2.0",
18 | "jsonschema==3.2.0",
19 | "pydantic==1.8.2",
20 | "diskcache==5.2.1",
21 | "dotmap==1.3.23",
22 | "cachecontrol==0.12.6",
23 | "lockfile==0.12.2",
24 | "colorclass==2.2.0",
25 | ],
26 | )
27 |
--------------------------------------------------------------------------------
/stats_generation/by_vaccine.py:
--------------------------------------------------------------------------------
1 | """Sums available appointments per vaccine type.
2 |
3 | Note: There are ~10% of centers that report multiple vaccine types.
4 | For those, the individual breakdown is not clear, so they are left out at the moment.
5 |
6 | Issue: https://github.com/CovidTrackerFr/vitemadose/issues/266
7 |
8 | Usage:
9 |
10 | ```shell
11 | python3 -m stats_generation.chronodoses_by_vaccine \
12 | --input="some/file.json" # Optional (should follow the same structure as data/output/info_centres.json.)\
13 | --output="put/it/here.json" # Optional
14 | ```
15 |
16 | """
17 | import argparse
18 | from collections import defaultdict
19 | import json
20 | import sys
21 | from functools import reduce
22 | from pathlib import Path
23 | from typing import DefaultDict, Iterator, List, Tuple
24 |
25 | from utils.vmd_config import get_conf_outputs, get_conf_outstats
26 |
27 | _default_input = Path(get_conf_outputs().get("last_scans"))
28 | _default_output = Path(get_conf_outstats().get("by_vaccine_type"))
29 |
30 |
31 | def parse_args(args: List[str]) -> argparse.Namespace:
32 | parser = argparse.ArgumentParser()
33 | parser.add_argument(
34 | "--input",
35 | default=_default_input,
36 | type=Path,
37 | help="File with the statistics per department. Should follow the same structure as info_centres.json.",
38 | )
39 | parser.add_argument(
40 | "--output",
41 | default=_default_output,
42 | type=Path,
43 | help="Where to put the resulting statistics.",
44 | )
45 | return parser.parse_args(args)
46 |
47 |
48 | def merge(data: dict, new: tuple) -> dict:
49 | vaccine_type, appointments = new
50 | if vaccine_type in data:
51 | data[vaccine_type] += appointments
52 | else:
53 | data[vaccine_type] = appointments
54 | return data
55 |
56 |
57 | def flatten_vaccine_types_schedules(data: dict) -> Iterator[Tuple[str, int]]:
58 | count = defaultdict(int)
59 | for center in data["centres_disponibles"]:
60 | for vaccine_name in center["vaccine_type"]:
61 | count[vaccine_name] += 1
62 | return (
63 | (vaccine_name, count[vaccine_name])
64 | for center in data["centres_disponibles"]
65 | for vaccine_name in center["vaccine_type"]
66 | )
67 |
68 |
69 | def main(argv):
70 | args = parse_args(argv[1:])
71 |
72 | with open(args.input) as f:
73 | data = json.load(f)
74 |
75 | available_center_schedules = flatten_vaccine_types_schedules(data)
76 | by_vaccine_type = reduce(merge, available_center_schedules, {})
77 |
78 | with open(args.output, "w") as f:
79 | json.dump(by_vaccine_type, f)
80 |
81 |
82 | if __name__ == "__main__":
83 | main(sys.argv)
84 |
--------------------------------------------------------------------------------
/stats_generation/stats_center_types.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | from datetime import datetime
4 |
5 | import pytz
6 | import requests
7 |
8 | from utils.vmd_config import get_conf_outstats, get_config, get_conf_inputs
9 |
10 | logger = logging.getLogger("scraper")
11 |
12 | DATA_AUTO = get_config().get("base_urls").get("gitlab_public_path")
13 |
14 |
15 | def compute_plateforme_data(centres_info):
16 | plateformes = {}
17 | center_types = {}
18 | for centre_dispo in centres_info["centres_disponibles"] + centres_info["centres_indisponibles"]:
19 |
20 | plateforme = centre_dispo["plateforme"]
21 | if not plateforme:
22 | plateforme = "Autre"
23 |
24 | center_type = centre_dispo["type"]
25 | if not center_type:
26 | center_type = "Autre"
27 |
28 | next_app = centre_dispo.get("prochain_rdv", None)
29 | if plateforme not in plateformes:
30 | plateforme_data = {"disponible": 0, "total": 0, "creneaux": 0}
31 | else:
32 | plateforme_data = plateformes[plateforme]
33 |
34 | if center_type not in center_types:
35 | center_type_data = {"disponible": 0, "total": 0, "creneaux": 0}
36 | else:
37 | center_type_data = center_types[center_type]
38 |
39 | plateforme_data["disponible"] += 1 if next_app else 0
40 | plateforme_data["total"] += 1
41 | plateforme_data["creneaux"] += centre_dispo.get("appointment_count", 0)
42 | plateformes[plateforme] = plateforme_data
43 |
44 | center_type_data["disponible"] += 1 if next_app else 0
45 | center_type_data["total"] += 1
46 | center_type_data["creneaux"] += centre_dispo.get("appointment_count", 0)
47 | center_types[center_type] = center_type_data
48 |
49 | return plateformes, center_types
50 |
51 |
52 | def generate_stats_center_types(centres_info):
53 | stats_path = get_conf_inputs().get("from_gitlab_public").get("center_types")
54 | stats_data = {"dates": [], "plateformes": {}, "center_types": {}}
55 |
56 | try:
57 | history_rq = requests.get(f"{DATA_AUTO}{stats_path}")
58 | data = history_rq.json()
59 | if data:
60 | stats_data = data
61 | except Exception:
62 | logger.warning(f"Unable to fetch {DATA_AUTO}{stats_path}: generating a template file.")
63 | ctz = pytz.timezone("Europe/Paris")
64 | current_time = datetime.now(tz=ctz).strftime("%Y-%m-%d %H:00:00")
65 | if current_time in stats_data["dates"]:
66 | with open(f"data/output/{stats_path}", "w") as stat_graph_file:
67 | json.dump(stats_data, stat_graph_file)
68 | logger.info(f"Stats file already updated: {stats_path}")
69 | return
70 |
71 | if "center_types" not in stats_data:
72 | stats_data["center_types"] = {}
73 |
74 | stats_data["dates"].append(current_time)
75 | current_calc = compute_plateforme_data(centres_info)
76 | for plateforme in current_calc[0]:
77 | plateform_data = current_calc[0][plateforme]
78 | if plateforme not in stats_data["plateformes"]:
79 | stats_data["plateformes"][plateforme] = {
80 | "disponible": [plateform_data["disponible"]],
81 | "total": [plateform_data["total"]],
82 | "creneaux": [plateform_data["creneaux"]],
83 | }
84 | continue
85 | current_data = stats_data["plateformes"][plateforme]
86 | current_data["disponible"].append(plateform_data["disponible"])
87 | current_data["total"].append(plateform_data["total"])
88 | current_data["creneaux"].append(plateform_data["creneaux"])
89 |
90 | for center_type in current_calc[1]:
91 | center_type_data = current_calc[1][center_type]
92 | if center_type not in stats_data["center_types"]:
93 | stats_data["center_types"][center_type] = {
94 | "disponible": [center_type_data["disponible"]],
95 | "total": [center_type_data["total"]],
96 | "creneaux": [center_type_data["creneaux"]],
97 | }
98 | continue
99 | current_data = stats_data["center_types"][center_type]
100 | current_data["disponible"].append(center_type_data["disponible"])
101 | current_data["total"].append(center_type_data["total"])
102 | current_data["creneaux"].append(center_type_data["creneaux"])
103 |
104 | with open(f"data/output/{stats_path}", "w") as stat_graph_file:
105 | json.dump(stats_data, stat_graph_file)
106 | logger.info(f"Updated stats file: {stats_path}")
107 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/tests/__init__.py
--------------------------------------------------------------------------------
/tests/dev/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/tests/dev/__init__.py
--------------------------------------------------------------------------------
/tests/dev/model/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/tests/dev/model/__init__.py
--------------------------------------------------------------------------------
/tests/dev/model/test_center.py:
--------------------------------------------------------------------------------
1 | import json
2 | from pathlib import Path
3 |
4 | from dev.model.department import Center, Schedule
5 |
6 |
7 | path = Path("tests", "fixtures", "utils", "info_centres.json")
8 |
9 | with open(path) as fixture:
10 | data = json.load(fixture)
11 |
12 |
13 | def test_unavailable_center():
14 | center = Center(**data["01"]["centres_indisponibles"][0])
15 | assert center.department == "01"
16 | assert center.appointment_count == 0
17 |
18 |
19 | def test_available_center():
20 | center = Center(**data["01"]["centres_disponibles"][0])
21 | assert center.department == "01"
22 | assert center.appointment_count == 35
23 |
24 |
25 | def test_center_iteration():
26 | center = Center(**data["01"]["centres_disponibles"][0])
27 | i = 0
28 | for _ in center:
29 | i += 1
30 | assert i > 0
31 |
--------------------------------------------------------------------------------
/tests/fixtures/avecmondoc/center.json:
--------------------------------------------------------------------------------
1 | {
2 | "appointment_by_phone_only": false,
3 | "appointment_count": 0,
4 | "departement": "28",
5 | "erreur": null,
6 | "internal_id": "amd159",
7 | "last_scan_with_availabilities": null,
8 | "location": {
9 | "city": "Chartres",
10 | "cp": "28000",
11 | "latitude": 48.447586,
12 | "longitude": 1.481373
13 | },
14 | "metadata": {
15 | "address": "21 Rue Nicole 28000 Chartres",
16 | "business_hours": {
17 | "Dimanche": "",
18 | "Jeudi": "08:30-12:30 13:30-17:00",
19 | "Lundi": "08:30-12:30 13:30-17:00",
20 | "Mardi": "08:30-12:30 13:30-17:00",
21 | "Mercredi": "08:30-12:30 13:30-17:00",
22 | "Samedi": "",
23 | "Vendredi": "08:30-12:30 13:30-17:00"
24 | },
25 | "phone_number": "0033143987678"
26 | },
27 | "nom": "Delphine ROUSSEAU",
28 | "plateforme": null,
29 | "prochain_rdv": null,
30 | "request_counts": null,
31 | "type": null,
32 | "url": "https://patient.avecmondoc.com/fiche/structure/delphine-rousseau-159",
33 | "vaccine_type": null
34 | }
--------------------------------------------------------------------------------
/tests/fixtures/avecmondoc/centerdict.json:
--------------------------------------------------------------------------------
1 | {
2 | "rdv_site_web": "https://patient.avecmondoc.com/fiche/structure/delphine-rousseau-159",
3 | "nom": "Delphine ROUSSEAU",
4 | "type": "drugstore",
5 | "business_hours": {
6 | "Lundi": "08:30-12:30 13:30-17:00",
7 | "Mardi": "08:30-12:30 13:30-17:00",
8 | "Mercredi": "08:30-12:30 13:30-17:00",
9 | "Jeudi": "08:30-12:30 13:30-17:00",
10 | "Vendredi": "08:30-12:30 13:30-17:00",
11 | "Samedi": "",
12 | "Dimanche": ""
13 | },
14 | "phone_number": "0033143987678",
15 | "address": "21 Rue Nicole 28000 Chartres, 28000 Chartres",
16 | "long_coor1": 1.481373,
17 | "lat_coor1": 48.447586,
18 | "com_nom": "Chartres",
19 | "com_cp": "28000",
20 | "com_insee": "28085",
21 | "gid": "amd159"
22 | }
--------------------------------------------------------------------------------
/tests/fixtures/avecmondoc/get_by_doctor.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": 159,
4 | "status": "disabled",
5 | "needSubscription": 1,
6 | "name": "Delphine ROUSSEAU",
7 | "subtitle": "Pharmacie",
8 | "finessNumber": null,
9 | "paymentMethods": "[]",
10 | "address": "21 Rue Nicole 28000 Chartres",
11 | "zipCode": "28000",
12 | "city": "Chartres",
13 | "country": "France",
14 | "phone": "0033143987678",
15 | "informations": "TEST PRESENTATION\nuygsuygsuysuusygsyuvgsygvs\nskuysvgusyvsuygsogosygs\nskuusvusgyusyogsuiygsuiygss",
16 | "accessInformations": null,
17 | "timezone": "Europe/Paris",
18 | "slug": "delphine-rousseau-159",
19 | "createdAt": "2020-11-20T01:49:56.000Z",
20 | "updatedAt": "2021-03-11T18:06:52.000Z",
21 | "coordinates": {
22 | "x": 1.481373,
23 | "y": 48.447586
24 | },
25 | "coordinatesFetchingStatus": "success",
26 | "prescriptionService": 0,
27 | "teletransmissionService": 0,
28 | "medicalTransportService": 1,
29 | "origin": "migration",
30 | "isOptic2000": 0
31 | }
32 | ]
--------------------------------------------------------------------------------
/tests/fixtures/avecmondoc/get_by_organization.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "organization": "Ordre des médecins",
4 | "phone": null,
5 | "stripeAccountStatus": "unverified",
6 | "photoPath": null,
7 | "sector": 0,
8 | "companyName": null,
9 | "companyAccessDetails": null,
10 | "companyAddress": "47 Avenue Wilson",
11 | "companyZipCode": "94300",
12 | "companyCity": "Vincennes",
13 | "companyCountry": "France",
14 | "companyCoordinates": null,
15 | "companyCoordinatesFetchingStatus": "not_found",
16 | "isPrivateCalendar": false,
17 | "optam": null,
18 | "holidayDaysAccepted": false,
19 | "timezone": "Europe/Paris",
20 | "appointmentDuration": 10,
21 | "workdayStart": "09:00:00",
22 | "workdayEnd": "18:00:00",
23 | "slug": "delphine-rousseau-216",
24 | "informations": null,
25 | "paymentMethods": [
26 | "Bank Check",
27 | "Card",
28 | "Cash",
29 | "Third-party Payment",
30 | "Vitale Card"
31 | ],
32 | "daysWeekToHide": [],
33 | "patientRestriction": "off",
34 | "selectedFormulaCode": null,
35 | "organizationInvitationId": null,
36 | "isProfileComplete": true,
37 | "showInPublicSearch": true,
38 | "delayBwtNowAndTimeAppointment": "0",
39 | "id": 216,
40 | "appUserId": 1066,
41 | "professionId": 14,
42 | "appUser": {
43 | "firstname": "Delphine",
44 | "lastname": "ROUSSEAU",
45 | "id": 1066
46 | },
47 | "specialitiesToDoctors": [
48 | {
49 | "isDefault": 1,
50 | "id": 85,
51 | "doctorId": 216,
52 | "specialityId": 190,
53 | "speciality": {
54 | "label": "Pharmacie",
55 | "code": "50",
56 | "nomenclature": "Pharmacie",
57 | "status": "enabled",
58 | "isMain": null,
59 | "color": null,
60 | "icon": null,
61 | "id": 190,
62 | "professionId": 24
63 | }
64 | }
65 | ]
66 | }
67 | ]
--------------------------------------------------------------------------------
/tests/fixtures/avecmondoc/get_doctor_slug.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": 216,
3 | "firstname": "Delphine",
4 | "lastname": "ROUSSEAU",
5 | "informations": null,
6 | "paymentMethods": [
7 | "Bank Check",
8 | "Card",
9 | "Cash",
10 | "Third-party Payment",
11 | "Vitale Card"
12 | ],
13 | "address": {
14 | "name": null,
15 | "address": "47 Avenue Wilson",
16 | "city": "Vincennes",
17 | "zipCode": "94300",
18 | "gps": null
19 | },
20 | "phone": null,
21 | "specialities": [
22 | {
23 | "label": "Pharmacie",
24 | "isDefault": 1,
25 | "id": 190
26 | }
27 | ],
28 | "sector": 0,
29 | "patientRestriction": "off"
30 | }
--------------------------------------------------------------------------------
/tests/fixtures/avecmondoc/get_reasons.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "reason": "Première injection vaccinale COVID-19 Pfizer",
4 | "code": "VGP",
5 | "type": "inOffice",
6 | "price": 0,
7 | "active": true,
8 | "deleted": false,
9 | "isALD": false,
10 | "isCMU": false,
11 | "showOnlyDoctor": false,
12 | "instructionText": "Après avoir confirmé ce rendez-vous, nous vous inviterons à prendre rendez-vous pour votre seconde injection. Lors du rendez-vous pensez à vous munir de votre ordonnance.",
13 | "modalTitle": "Validation du RDV",
14 | "modalContent": "Vous allez être redirigé vers la prise de rendez-vous pour votre seconde injection à réaliser dans les 9 à 12 semaines suivant la précédente.",
15 | "checkboxContent": "Je déclare sur l'honneur être éligible à la vaccination ou prendre rendez-vous pour un proche éligible.",
16 | "isCheckboxRequired": true,
17 | "httpReturnLink": "https://vaccination-info-service.fr/Les-maladies-et-leurs-vaccins/COVID-19",
18 | "nextAction": "appointment",
19 | "id": 604,
20 | "specialityId": 190,
21 | "organizationId": 159
22 | },
23 | {
24 | "reason": "Seconde injection vaccinale COVID-19 Janssen",
25 | "code": "VGP",
26 | "type": "inOffice",
27 | "price": 0,
28 | "active": true,
29 | "deleted": false,
30 | "isALD": false,
31 | "isCMU": false,
32 | "showOnlyDoctor": false,
33 | "instructionText": null,
34 | "modalTitle": "Validation du RDV",
35 | "modalContent": "La seconde injection est à réaliser dans les 9 à 12 semaines suivant la précédente.",
36 | "checkboxContent": "Je déclare sur l'honneur être éligible à la vaccination ou prendre rendez-vous pour un proche éligible.",
37 | "isCheckboxRequired": true,
38 | "httpReturnLink": "https://vaccination-info-service.fr/Les-maladies-et-leurs-vaccins/COVID-19",
39 | "nextAction": "confirmation",
40 | "id": 605,
41 | "specialityId": 190,
42 | "organizationId": 159
43 | },
44 | {
45 | "reason": "Vaccination grippe",
46 | "code": "VGP",
47 | "type": "inOffice",
48 | "price": 0,
49 | "active": true,
50 | "deleted": false,
51 | "isALD": false,
52 | "isCMU": false,
53 | "showOnlyDoctor": false,
54 | "instructionText": null,
55 | "modalTitle": null,
56 | "modalContent": null,
57 | "checkboxContent": null,
58 | "isCheckboxRequired": true,
59 | "httpReturnLink": null,
60 | "nextAction": null,
61 | "id": 606,
62 | "specialityId": 190,
63 | "organizationId": 159
64 | }
65 | ]
--------------------------------------------------------------------------------
/tests/fixtures/avecmondoc/iterator_search_result.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": [
3 | {
4 | "name": "Delphine ROUSSEAU",
5 | "url": "https://patient.avecmondoc.com/fiche/structure/delphine-rousseau-159",
6 | "address": "21 Rue Nicole 28000 Chartres",
7 | "zipCode": "28000",
8 | "city": "Chartres",
9 | "country": "France",
10 | "businessHoursCovidCount": 10
11 | }
12 | ],
13 | "page": 1,
14 | "pages": 1,
15 | "limit": 1,
16 | "hasPreviousPage": false,
17 | "hasNextPage": false,
18 | "count": 1
19 | }
--------------------------------------------------------------------------------
/tests/fixtures/avecmondoc/search-result.schema:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-04/schema#",
3 | "type": "object",
4 | "properties": {
5 | "data": {
6 | "type": "array",
7 | "items": {
8 | "type": "object",
9 | "properties": {
10 | "name": {
11 | "type": "string"
12 | },
13 | "url": {
14 | "type": "string"
15 | },
16 | "address": {
17 | "type": [
18 | "string",
19 | "null"
20 | ]
21 | },
22 | "zipCode": {
23 | "type": [
24 | "string",
25 | "null"
26 | ]
27 | },
28 | "city": {
29 | "type": [
30 | "string",
31 | "null"
32 | ]
33 | },
34 | "country": {
35 | "type": [
36 | "string",
37 | "null"
38 | ]
39 | },
40 | "businessHoursCovidCount": {
41 | "type": "integer"
42 | }
43 | },
44 | "required": [
45 | "name",
46 | "url",
47 | "address",
48 | "zipCode",
49 | "city",
50 | "country",
51 | "businessHoursCovidCount"
52 | ]
53 | }
54 | },
55 | "page": {
56 | "type": "integer"
57 | },
58 | "pages": {
59 | "type": "integer"
60 | },
61 | "limit": {
62 | "type": "integer"
63 | },
64 | "hasPreviousPage": {
65 | "type": "boolean"
66 | },
67 | "hasNextPage": {
68 | "type": "boolean"
69 | },
70 | "count": {
71 | "type": "integer"
72 | }
73 | },
74 | "required": [
75 | "data",
76 | "page",
77 | "pages",
78 | "limit",
79 | "hasPreviousPage",
80 | "hasNextPage",
81 | "count"
82 | ]
83 | }
--------------------------------------------------------------------------------
/tests/fixtures/bimedoc/bimedoc_center_info.json:
--------------------------------------------------------------------------------
1 | {
2 | "phone_number": "+33321382253",
3 | "vaccine_names": [
4 | "Pfizer-BioNTech"
5 | ],
6 | "rdv_site_web": "https://app.bimedoc.com/application/scheduler/9cf46288-0080-4a8d-8856-8e9998ced9f7?vmd=true",
7 | "platform_is": "bimedoc",
8 | "gid": "bimedoc9cf46288-0080-4a8d-8856-8e9998ced9f7",
9 | "nom": "Pharmacie de Thêatre | Silvie",
10 | "com_insee": "62905",
11 | "address": "51 Place du Marechal Foch, 62500 Saint-Omer",
12 | "long_coor1": 2.252316,
13 | "lat_coor1": 50.750673,
14 | "type": "drugstore"
15 | }
--------------------------------------------------------------------------------
/tests/fixtures/bimedoc/bimedoc_centers.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "phone_number": "+33478542582",
4 | "vaccine_names": [
5 | "Moderna"
6 | ],
7 | "rdv_site_web": "https://app.bimedoc.com/application/scheduler/37df4bc4-8afb-46e5-964a-3e91b72e44b3?vmd=true",
8 | "platform_is": "bimedoc",
9 | "gid": "bimedoc37df4bc4-8afb-46e5-964a-3e91b72e44b3",
10 | "nom": "PHARMACIE GRANDCLEMENT",
11 | "com_insee": "69266",
12 | "address": "2 Rue LEON BLUM, 69100 Villeurbanne",
13 | "long_coor1": 4.89097,
14 | "lat_coor1": 45.759376,
15 | "type": "drugstore"
16 | },
17 | {
18 | "phone_number": "+33321381376",
19 | "vaccine_names": [
20 | "Pfizer-BioNTech",
21 | "Moderna",
22 | "Janssen"
23 | ],
24 | "rdv_site_web": "https://app.bimedoc.com/application/scheduler/a8c890ec-f95c-40c9-bb6b-db8d4a0877cc?vmd=true",
25 | "platform_is": "bimedoc",
26 | "gid": "bimedoca8c890ec-f95c-40c9-bb6b-db8d4a0877cc",
27 | "nom": "SELARL DE PHARMACIENS D'OFFICINE",
28 | "com_insee": "62905",
29 | "address": "158 Rue DE DUNKERQUE, 62500 Saint-Omer",
30 | "long_coor1": 2.258574,
31 | "lat_coor1": 50.753255,
32 | "type": "drugstore"
33 | },
34 | {
35 | "phone_number": "+33321382253",
36 | "vaccine_names": [
37 | "Pfizer-BioNTech"
38 | ],
39 | "rdv_site_web": "https://app.bimedoc.com/application/scheduler/9cf46288-0080-4a8d-8856-8e9998ced9f7?vmd=true",
40 | "platform_is": "bimedoc",
41 | "gid": "bimedoc9cf46288-0080-4a8d-8856-8e9998ced9f7",
42 | "nom": "Pharmacie de Thêatre | Silvie",
43 | "com_insee": "62905",
44 | "address": "51 Place du Marechal Foch, 62500 Saint-Omer",
45 | "long_coor1": 2.252316,
46 | "lat_coor1": 50.750673,
47 | "type": "drugstore"
48 | }
49 | ]
--------------------------------------------------------------------------------
/tests/fixtures/bimedoc/slots_unavailable.json:
--------------------------------------------------------------------------------
1 | {
2 | "slots": [
3 | ]
4 | }
5 |
--------------------------------------------------------------------------------
/tests/fixtures/doctolib/basic-availabilities.json:
--------------------------------------------------------------------------------
1 | {
2 | "availabilities": [
3 | {
4 | "slots": [
5 | {
6 | "start_date": "2021-04-10T21:45:00.000+02:00"
7 | },
8 | {
9 | "start_date": "2021-03-25T21:45:00.000+02:00"
10 | }
11 | ]
12 | }
13 | ]
14 | }
--------------------------------------------------------------------------------
/tests/fixtures/doctolib/basic-booking.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "profile": {
4 | "id": "123456789"
5 | },
6 | "visit_motive_categories": [],
7 | "visit_motives": [
8 | {
9 | "id": 2,
10 | "name": "1ère injection vaccin COVID-19 (Moderna)",
11 | "vaccination_motive": true,
12 | "first_shot_motive": true,
13 | "ref_visit_motive_id": 6970
14 | }
15 | ],
16 | "agendas": [
17 | {
18 | "id": 3,
19 | "visit_motive_ids_by_practice_id": {
20 | "165752": [
21 | 2
22 | ]
23 | },
24 | "booking_disabled": false
25 | }
26 | ],
27 | "places": [
28 | {
29 | "id": "practice-165752",
30 | "practice_ids": [
31 | 165752
32 | ]
33 | }
34 | ]
35 | }
36 | }
--------------------------------------------------------------------------------
/tests/fixtures/doctolib/category-availabilities.json:
--------------------------------------------------------------------------------
1 | {
2 | "availabilities": [
3 | {
4 | "slots": [
5 | {
6 | "start_date": "2021-04-10"
7 | }
8 | ]
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/tests/fixtures/doctolib/category-booking.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "visit_motive_categories": [
4 | {
5 | "id": 1,
6 | "name": "Non professionnels de santé"
7 | }
8 | ],
9 | "visit_motives": [
10 | {
11 | "id": 2,
12 | "visit_motive_category_id": 1,
13 | "ref_visit_motive_id": 8740,
14 | "name": "1ère injection vaccin COVID-19 (Moderna)",
15 | "vaccination_motive": true,
16 | "first_shot_motive": true
17 | }
18 | ],
19 | "agendas": [
20 | {
21 | "id": 3,
22 | "visit_motive_ids_by_practice_id": {
23 | "165752": [
24 | 2
25 | ]
26 | },
27 | "booking_disabled": false
28 | }
29 | ],
30 | "places": [
31 | {
32 | "id": "practice-165752",
33 | "practice_ids": [
34 | 165752
35 | ]
36 | }
37 | ]
38 | }
39 | }
--------------------------------------------------------------------------------
/tests/fixtures/doctolib/next-slot-availabilities.json:
--------------------------------------------------------------------------------
1 | {
2 | "availabilities": [
3 | {
4 | "slots": []
5 | }
6 | ],
7 | "next_slot": "2021-04-10T12:00:00.000+02:00"
8 | }
9 |
--------------------------------------------------------------------------------
/tests/fixtures/doctolib/next-slot-booking.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "visit_motive_categories": [
4 | {
5 | "id": 1,
6 | "name": "Non professionnels de santé"
7 | }
8 | ],
9 | "visit_motives": [
10 | {
11 | "id": 2,
12 | "visit_motive_category_id": 1,
13 | "name": "1ère injection vaccin COVID-19 (Moderna)",
14 | "ref_visit_motive_id": 8740,
15 | "vaccination_motive": true,
16 | "first_shot_motive": true
17 | }
18 | ],
19 | "agendas": [
20 | {
21 | "id": 3,
22 | "visit_motive_ids_by_practice_id": {
23 | "165752": [
24 | 2
25 | ]
26 | },
27 | "booking_disabled": false
28 | }
29 | ],
30 | "places": [
31 | {
32 | "id": "practice-165752",
33 | "practice_ids": [
34 | 165752
35 | ]
36 | }
37 | ]
38 | }
39 | }
--------------------------------------------------------------------------------
/tests/fixtures/keldoc/center1-cabinet-16913.json:
--------------------------------------------------------------------------------
1 | [{
2 | "motive_category_id": 2526,
3 | "name": "Je suis une personne de + 70 ANS (PF)",
4 | "position": 0,
5 | "resource_type": "Clinic",
6 | "motives": [{
7 | "id": 81484,
8 | "name": "1ère injection Vaccin COVID +70 ANS (PF)",
9 | "position": 0,
10 | "teleconsultation": false,
11 | "booking_delay_until": 1440,
12 | "agendas": [{
13 | "id": 49335,
14 | "name": "Equipe de vaccination LE FAOUET - Maison de santé",
15 | "hidden": true
16 | }, {
17 | "id": 51414,
18 | "name": "Equipe de vaccination 2 LE FAOUET - Maison de santé",
19 | "hidden": true
20 | }]
21 | }]
22 | }, {
23 | "motive_category_id": 2527,
24 | "name": "Je suis professionnel.le de santé liberal.e (PF)",
25 | "position": 1,
26 | "resource_type": "Clinic",
27 | "motives": [{
28 | "id": 81486,
29 | "name": "1ère injection Vaccin COVID LIBERAUX (PF)",
30 | "position": 0,
31 | "teleconsultation": false,
32 | "booking_delay_until": 1440,
33 | "agendas": [{
34 | "id": 49335,
35 | "name": "Equipe de vaccination LE FAOUET - Maison de santé",
36 | "hidden": true
37 | }]
38 | }]
39 | }, {
40 | "motive_category_id": 2528,
41 | "name": "Je suis professionnel.le du GHBS (PF)",
42 | "position": 2,
43 | "resource_type": "Clinic",
44 | "motives": [{
45 | "id": 81488,
46 | "name": "1ère injection Vaccin COVID Professionnel.le.s GHBS (PF)",
47 | "position": 0,
48 | "teleconsultation": false,
49 | "booking_delay_until": 1440,
50 | "agendas": [{
51 | "id": 49335,
52 | "name": "Equipe de vaccination LE FAOUET - Maison de santé",
53 | "hidden": true
54 | }]
55 | }]
56 | }, {
57 | "motive_category_id": 2763,
58 | "name": "Je suis une personne vulnérable à très haut risque (PF)",
59 | "position": 999,
60 | "resource_type": "Clinic",
61 | "motives": [{
62 | "id": 82874,
63 | "name": "1ère injection Vaccin COVID personne vulnérable à très haut risque (PF)",
64 | "position": 999,
65 | "teleconsultation": false,
66 | "booking_delay_until": 720,
67 | "agendas": [{
68 | "id": 49335,
69 | "name": "Equipe de vaccination LE FAOUET - Maison de santé",
70 | "hidden": true
71 | }, {
72 | "id": 51414,
73 | "name": "Equipe de vaccination 2 LE FAOUET - Maison de santé",
74 | "hidden": true
75 | }]
76 | }]
77 | }]
--------------------------------------------------------------------------------
/tests/fixtures/keldoc/center1-cabinet-18780.json:
--------------------------------------------------------------------------------
1 | [{
2 | "motive_category_id": 2526,
3 | "name": "Je suis une personne de + 70 ANS (PF)",
4 | "position": 0,
5 | "resource_type": "Clinic",
6 | "motives": [{
7 | "id": 81484,
8 | "name": "1ère injection Vaccin COVID +70 ANS (PF)",
9 | "position": 0,
10 | "teleconsultation": false,
11 | "booking_delay_until": 1440,
12 | "agendas": [{
13 | "id": 49335,
14 | "name": "Equipe de vaccination LE FAOUET - Maison de santé",
15 | "hidden": true
16 | }, {
17 | "id": 51414,
18 | "name": "Equipe de vaccination 2 LE FAOUET - Maison de santé",
19 | "hidden": true
20 | }]
21 | }]
22 | }, {
23 | "motive_category_id": 2527,
24 | "name": "Je suis professionnel.le de santé liberal.e (PF)",
25 | "position": 1,
26 | "resource_type": "Clinic",
27 | "motives": [{
28 | "id": 81486,
29 | "name": "1ère injection Vaccin COVID LIBERAUX (PF)",
30 | "position": 0,
31 | "teleconsultation": false,
32 | "booking_delay_until": 1440,
33 | "agendas": [{
34 | "id": 49335,
35 | "name": "Equipe de vaccination LE FAOUET - Maison de santé",
36 | "hidden": true
37 | }]
38 | }]
39 | }, {
40 | "motive_category_id": 2528,
41 | "name": "Je suis professionnel.le du GHBS (PF)",
42 | "position": 2,
43 | "resource_type": "Clinic",
44 | "motives": [{
45 | "id": 81488,
46 | "name": "1ère injection Vaccin COVID Professionnel.le.s GHBS (PF)",
47 | "position": 0,
48 | "teleconsultation": false,
49 | "booking_delay_until": 1440,
50 | "agendas": [{
51 | "id": 49335,
52 | "name": "Equipe de vaccination LE FAOUET - Maison de santé",
53 | "hidden": true
54 | }]
55 | }]
56 | }, {
57 | "motive_category_id": 2763,
58 | "name": "Je suis une personne vulnérable à très haut risque (PF)",
59 | "position": 999,
60 | "resource_type": "Clinic",
61 | "motives": [{
62 | "id": 82874,
63 | "name": "1ère injection Vaccin COVID personne vulnérable à très haut risque (PF)",
64 | "position": 999,
65 | "teleconsultation": false,
66 | "booking_delay_until": 720,
67 | "agendas": [{
68 | "id": 49335,
69 | "name": "Equipe de vaccination LE FAOUET - Maison de santé",
70 | "hidden": true
71 | }, {
72 | "id": 51414,
73 | "name": "Equipe de vaccination 2 LE FAOUET - Maison de santé",
74 | "hidden": true
75 | }]
76 | }]
77 | }]
--------------------------------------------------------------------------------
/tests/fixtures/keldoc/center1-cabinet.json:
--------------------------------------------------------------------------------
1 | [{
2 | "id": 18780,
3 | "name": "Centre de Vaccination Caudan Ville",
4 | "location": "Salle Des Fêtes Joseph Le Ravallec 10 Rue du 19eme Dragon, 56850, Caudan,France",
5 | "img": "https://www.keldoc.com/assets/uploads/clinic/photos/2563/square_medium_groupe-hospitalier-bretagne-sud-lorient-hopital-du-scorff_f7dec759-df77-4cea-b975-547afb50bb7b.jpg",
6 | "latitude": 47.811366,
7 | "longitude": -3.353233
8 | }, {
9 | "id": 16913,
10 | "name": "Centre de vaccination - LE FAOUET - Maison de santé",
11 | "location": "104 Rue de Saint Fiacre, 56320 Le Faouët",
12 | "img": "https://www.keldoc.com/assets/uploads/clinic/photos/2563/square_medium_groupe-hospitalier-bretagne-sud-lorient-hopital-du-scorff_f7dec759-df77-4cea-b975-547afb50bb7b.jpg",
13 | "latitude": 48.031233,
14 | "longitude": -3.490112
15 | }, {
16 | "id": 16910,
17 | "name": "Centre de vaccination K2 Lorient La Base",
18 | "location": "Rue Etienne d’Orves, 56100 Lorient",
19 | "img": "https://www.keldoc.com/assets/uploads/clinic/photos/2563/square_medium_groupe-hospitalier-bretagne-sud-lorient-hopital-du-scorff_f7dec759-df77-4cea-b975-547afb50bb7b.jpg",
20 | "latitude": 47.732493,
21 | "longitude": -3.37471
22 | }, {
23 | "id": 16571,
24 | "name": "Centre de vaccination pour les Professionnels - GHBS Lorient - Bâtiment Onc'Oriant",
25 | "location": "1 Rampe de l'Hôpital des Armées, 56100 Lorient ",
26 | "img": "https://www.keldoc.com/assets/uploads/cabinet/photos/16571/square_medium_centre-de-vaccination-du-ghbs-lorient-hopital-du-scorff_c00e58f5-4606-43c8-9ebe-fc477e91e5bc.jpg",
27 | "latitude": 47.753227,
28 | "longitude": -3.359273
29 | }, {
30 | "id": 16579,
31 | "name": "GHBS Professionnels Quimperlé Hôpital La Villeneuve et Hôpital Le Faouët ",
32 | "location": "20 bis avenue Général Leclerc, 29300 Quimperlé",
33 | "img": "https://www.keldoc.com/assets/uploads/cabinet/photos/16579/square_medium_centre-de-vaccination-du-ghbs-quimperle-hopital-la-villeneuve_2fd68618-2dd7-4690-af7c-53129f23db66.jpg",
34 | "latitude": 47.868873,
35 | "longitude": -3.557478
36 | }]
--------------------------------------------------------------------------------
/tests/fixtures/keldoc/center1-info.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": 2563,
3 | "specialties": [{
4 | "id": 144,
5 | "name": "Maladies infectieuses",
6 | "skills": [{
7 | "name": "Centre de vaccination COVID-19"
8 | }]
9 | },
10 | {
11 | "id": 9302,
12 | "name": "foobar",
13 | "skills": [{
14 | "name": "put a bar in foo?"
15 | }]
16 | }
17 | ]
18 | }
--------------------------------------------------------------------------------
/tests/fixtures/keldoc/center1-motives.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": 81484,
4 | "vaccine_type": null,
5 | "agendas": [
6 | 49335,
7 | 51414
8 | ],
9 | "dose": "1"
10 | },
11 | {
12 | "id": 81486,
13 | "vaccine_type": null,
14 | "agendas": [
15 | 49335
16 | ],
17 | "dose": "1"
18 | },
19 | {
20 | "id": 81488,
21 | "vaccine_type": null,
22 | "agendas": [
23 | 49335
24 | ],
25 | "dose": "1"
26 | },
27 | {
28 | "id": 82874,
29 | "vaccine_type": null,
30 | "agendas": [
31 | 49335,
32 | 51414
33 | ],
34 | "dose": "1"
35 | }
36 | ]
--------------------------------------------------------------------------------
/tests/fixtures/keldoc/center1-timetable-81484.json:
--------------------------------------------------------------------------------
1 | {
2 | "text": "Prochain RDV disponible le 20 Avril 2021 \u00e0 16h55",
3 | "date": "2021-04-20T16:55:00.000+02:00"
4 | }
--------------------------------------------------------------------------------
/tests/fixtures/keldoc/center1-timetable-81486.json:
--------------------------------------------------------------------------------
1 | {
2 | "text": "La prise de RDV pour cette consultation est uniquement disponible au :",
3 | "num": "02 97 06 97 94",
4 | "num_text": "02 97 06... Afficher le num\u00e9ro",
5 | "phone_number": "+33297069794"
6 | }
--------------------------------------------------------------------------------
/tests/fixtures/keldoc/center1-timetable-81488.json:
--------------------------------------------------------------------------------
1 | {
2 | "text": "La prise de RDV pour cette consultation est uniquement disponible au :",
3 | "num": "02 97 06 97 94",
4 | "num_text": "02 97 06... Afficher le num\u00e9ro",
5 | "phone_number": "+33297069794"
6 | }
--------------------------------------------------------------------------------
/tests/fixtures/keldoc/center1-timetable-82874.json:
--------------------------------------------------------------------------------
1 | {
2 | "text": "Prochain RDV disponible le 20 Avril 2021 \u00e0 16h55",
3 | "date": "2021-04-20T16:55:00.000+02:00"
4 | }
--------------------------------------------------------------------------------
/tests/fixtures/keldoc/department-ain.json:
--------------------------------------------------------------------------------
1 | {
2 | "options": {
3 | "location_input": "Ain",
4 | "location_text": "Ain (01)",
5 | "next_page": false,
6 | "previous_page": false,
7 | "empty": false,
8 | "search_type": "departement",
9 | "specialty_id": "maladies-infectieuses"
10 | },
11 | "results": {
12 | "section_1": {
13 | "data": [
14 | {
15 | "id": 17136,
16 | "type": "Cabinet",
17 | "img": "https://www.keldoc.com/assets/be/front/clinics/clinic-missing-square_medium-1f94da4f68250e10b53f55d503bc6884.png",
18 | "alt": "Centre de vaccination de Jean Marinet à Valserhône 01200",
19 | "title": "Centre de vaccination de Jean Marinet",
20 | "sub_title": "Jean Marinet - Centre de vaccination COVID",
21 | "convention_type_name": null,
22 | "cabinet": {
23 | "street": "Place Jeanne d'arc",
24 | "zipcode": "01200",
25 | "city": "Valserhône",
26 | "location": "Place Jeanne d'arc, 01200, Valserhône"
27 | },
28 | "agenda": {
29 | "ids": [
30 | 51314,
31 | 50247,
32 | 51313,
33 | 49968,
34 | 49969,
35 | 56107
36 | ],
37 | "specialty_id": 144,
38 | "next_availability": "2021-05-20T09:30:00.000+02:00",
39 | "default_online_motive_id": 84927
40 | },
41 | "specialty_ids": [
42 | 144
43 | ],
44 | "coordinates": "46.108467,5.82781",
45 | "url": "/cabinet-medical/valserhone-01200/jean-marinet/centre-de-vaccination-de-jean-marinet"
46 | }
47 | ],
48 | "empty_description": null,
49 | "section_title": "Prendre un RDV avec un maladies infectieuses dans l’Ain (01)"
50 | },
51 | "section_2": {
52 | "data": [],
53 | "section_title": "Contacter un maladies infectieuses dans l’Ain (01)"
54 | },
55 | "section_3": {
56 | "data": []
57 | },
58 | "section_4": {
59 | "data": []
60 | }
61 | },
62 | "seo": {
63 | "title": "Maladies infectieuses à Ain (01)",
64 | "keywords": "Maladies infectieuses, Maladies infectieuses Ain 01, 01",
65 | "description": "Trouvez votre Maladies infectieuses à Ain 01 et prenez rendez-vous directement en ligne en quelques clics seulement ! Rapide, Gratuit et Sécurisé."
66 | }
67 | }
--------------------------------------------------------------------------------
/tests/fixtures/keldoc/resource-ain.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": 2737,
3 | "title": "Jean Marinet - Centre de vaccination COVID",
4 | "url": "/cabinet-medical/valserhone-01200/jean-marinet",
5 | "description": "est un centre de vaccination COVID
",
6 | "main_description": "est un centre de vaccination COVID
",
7 | "cabinets": [
8 | {
9 | "id": 17136,
10 | "name": "Centre de vaccination de Jean Marinet",
11 | "location": "Place Jeanne d'arc, 01200, Valserhône",
12 | "slug": "centre-de-vaccination-de-jean-marinet",
13 | "latitude": 46.108467,
14 | "longitude": 5.82781,
15 | "transportations": []
16 | }
17 | ],
18 | "type": "Clinic",
19 | "img": "https://www.keldoc.com/assets/be/front/clinics/clinic-missing-square_medium-1f94da4f68250e10b53f55d503bc6884.png",
20 | "specialties": [
21 | {
22 | "id": 144,
23 | "name": "Maladies infectieuses",
24 | "skills": [
25 | {
26 | "name": "Centre de vaccination COVID-19"
27 | }
28 | ]
29 | }
30 | ],
31 | "seo": {
32 | "noindex": false,
33 | "title": "Jean Marinet - Centre de vaccination COVID (01200) : Prendre RDV en ligne",
34 | "description": "Jean Marinet - Centre de vaccination COVID. Prenez rendez-vous en ligne chez votre maladies infectieuses grâce à KelDoc."
35 | },
36 | "breadcrumbs": [
37 | {
38 | "title": "Accueil",
39 | "path": "/"
40 | },
41 | {
42 | "title": "Ain (01)"
43 | },
44 | {
45 | "title": "Valserhône 01200"
46 | },
47 | {
48 | "title": "Jean Marinet - Centre de vaccination COVID",
49 | "path": "/cabinet-medical/valserhone-01200/jean-marinet"
50 | }
51 | ]
52 | }
--------------------------------------------------------------------------------
/tests/fixtures/maiia/availability-closests.json:
--------------------------------------------------------------------------------
1 | {
2 | "availabilityCount": 1,
3 | "closestPhysicalAvailability": {
4 | "id": "607869c8d6b1747a79b0eace",
5 | "practitionerId": "6007098df398765a70e9f560",
6 | "centerId": "6005bad42475225a68a3f19f",
7 | "timeSlotId": "6065b6fbfce3b9120436a859",
8 | "weekId": "6065ab31e02c8d118b4e4387",
9 | "weekTemplateCycleId": "6065ab31e02c8d118b4e4383",
10 | "consultationReasonId": "6007098df398765a70e9f564",
11 | "creationDate": "2021-04-15T16:28:56.249Z",
12 | "updateDate": "2021-04-15T16:28:56.249Z",
13 | "startDateTime": "2021-05-26T12:55:00.000Z",
14 | "endDateTime": "2021-05-26T13:00:00.000Z",
15 | "percentageNewPatient": 85.41666666666666,
16 | "usedResource": [],
17 | "substitute": {}
18 | },
19 | "firstPhysicalStartDateTime": "2021-05-26T12:55:00.000Z"
20 | }
--------------------------------------------------------------------------------
/tests/fixtures/maiia/consultation-reason-hcd.json:
--------------------------------------------------------------------------------
1 | {"items":[{"id":"6065da8801a987762e623dc9","name":"Dispositif CNAM - \"Allez-vers\" +de 75 ans","position":5,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"6054856eabadcc540688049e","name":"Première injection - etudiant en santé - avec attestation de formation","position":3,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"605b33a7664f4e3807876189","name":"Première injection professionnel de santé hors hôpital du gier (moins de 55ans)","position":4,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"605b337c3420b25a1c4e2f90","name":"Première injection professionnel de santé hôpital du gier","position":3,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"605dc40ddcb2f83f2c77fc2f","name":"Première injection vaccin anti covid-19 ( +50 ans avec comorbidité)","position":2,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"605dc2e8014785294b527b0e","name":"Première injection vaccin anti covid-19 (+50 ans avec comorbidité)","position":2,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"605dc53c40f8fe05bb02f945","name":"Première injection vaccin anti covid-19 (personnes +18 ans à très haut-risque avec ordonnance médicale)","position":1,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"603686d04428f40a0ef44d23","name":"Première injection vaccin anti covid-19 (personnes +18ans à très haut risque avec ordonnance médicale) - rive de gier","position":1,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"602a56db18afb4512ac77c19","name":"Première injection vaccin anti covid-19 (personnes +60 ans)","position":0,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"603686d04428f40a0ef44d27","name":"Première injection vaccin anti covid-19 (personnes +60 ans) - rive de gier","position":0,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42}],"total":10}
--------------------------------------------------------------------------------
/tests/fixtures/mesoigner/mesoigner_center_info.json:
--------------------------------------------------------------------------------
1 | {
2 | "rdv_site_web": "https://pharmacie-des-pyrenees.pharmaxv.fr/rendez-vous/vaccination/269-vaccination-covid-19/pre-inscription",
3 | "platform_is": "mesoigner",
4 | "name": "Pharmacie des Pyrénées",
5 | "center_type": "pharmacie",
6 | "booking_disabled": false,
7 | "phone_number": "+33562450461",
8 | "gid": "1722",
9 | "nom": "Pharmacie Des Ailes",
10 | "com_insee": "03310",
11 | "address": "1 Impasse du Stade, 65310 ODOS",
12 | "long_coor1": 3.41534,
13 | "lat_coor1": 46.14076,
14 | "business_hours": {
15 | "lundi": "08:00-20:00",
16 | "mardi": "08:00-20:00",
17 | "mercredi": "08:00-20:00",
18 | "jeudi": "08:00-20:00",
19 | "vendredi": "08:00-20:00",
20 | "samedi": "08:00-20:00",
21 | "dimanche": null
22 | },
23 | "type": "drugstore"
24 | }
--------------------------------------------------------------------------------
/tests/fixtures/mesoigner/mesoigner_centers.json:
--------------------------------------------------------------------------------
1 | [{"adress_city": "CHAUSSIN",
2 | "adress_street": "6 Rue de l'Hotel de Ville",
3 | "booking_disabled": false,
4 | "center_type": "pharmacie",
5 | "id": "1707",
6 | "name": "Pharmacie Grizard",
7 | "opening_hours": [{"day": 1,
8 | "ranges": [["09:00", "12:00"], ["14:30", "19:00"]]},
9 | {"day": 2,
10 | "ranges": [["09:00", "12:00"], ["14:30", "19:00"]]},
11 | {"day": 3,
12 | "ranges": [["09:00", "12:00"], ["14:30", "19:00"]]},
13 | {"day": 4,
14 | "ranges": [["09:00", "12:00"], ["14:30", "19:00"]]},
15 | {"day": 5,
16 | "ranges": [["09:00", "12:00"], ["14:30", "19:00"]]},
17 | {"day": 6,
18 | "ranges": [["09:00", "12:00"], ["14:30", "17:00"]]},
19 | {"day": 7, "ranges": null}],
20 | "phone_number": "+33384818141",
21 | "platform_is": "mesoigner",
22 | "position": {"latitude": "46.96735700", "longitude": "5.40696680"},
23 | "rdv_site_web": "https://pharmacie-chaussin.pharm-upp.fr/rendez-vous/vaccination/502-vaccination-covid-19/pre-inscription?origin=vmd",
24 | "zipcode": "39120"},
25 |
26 | {"adress_city": "ÉTUEFFONT",
27 | "adress_street": "6 Rue de Giromagny",
28 | "booking_disabled": false,
29 | "center_type": "pharmacie",
30 | "id": "1709",
31 | "name": "Pharmacie du Fayé",
32 | "opening_hours": [{"day": 1,
33 | "ranges": [["09:00", "12:00"], ["14:00", "19:00"]]},
34 | {"day": 2,
35 | "ranges": [["09:00", "12:00"], ["14:00", "19:00"]]},
36 | {"day": 3,
37 | "ranges": [["09:00", "12:00"], ["14:00", "19:00"]]},
38 | {"day": 4,
39 | "ranges": [["09:00", "12:00"], ["14:00", "19:00"]]},
40 | {"day": 5,
41 | "ranges": [["09:00", "12:00"], ["14:00", "19:00"]]},
42 | {"day": 6, "ranges": [["09:00", "12:00"]]},
43 | {"day": 7, "ranges": null}],
44 | "phone_number": "+33384546226",
45 | "platform_is": "mesoigner",
46 | "position": {"latitude": "47.72310260", "longitude": "6.91901160"},
47 | "rdv_site_web": "https://pharmacie-etueffont.pharm-upp.fr/rendez-vous/vaccination/670-vaccination-covid-19/pre-inscription?origin=vmd",
48 | "zipcode": "90170"},
49 |
50 | {"adress_city": "Les Sables d'Olonne",
51 | "adress_street": "87 Avenue François Mitterrand",
52 | "booking_disabled": false,
53 | "center_type": "pharmacie",
54 | "id": "1712",
55 | "name": "Pharmacie Ylium",
56 | "opening_hours": [{"day": 1, "ranges": [["08:30", "20:00"]]},
57 | {"day": 2, "ranges": [["08:30", "20:00"]]},
58 | {"day": 3, "ranges": [["08:30", "20:00"]]},
59 | {"day": 4, "ranges": [["08:30", "20:00"]]},
60 | {"day": 5, "ranges": [["08:30", "20:00"]]},
61 | {"day": 6, "ranges": [["08:30", "20:00"]]},
62 | {"day": 7, "ranges": null}],
63 | "phone_number": "+33251328736",
64 | "platform_is": "mesoigner",
65 | "position": {"latitude": "46.51561320", "longitude": "-1.77794900"},
66 | "rdv_site_web": "https://pharmacie-ylium.apothical.fr/rendez-vous/vaccination/528-vaccination-covid-19/pre-inscription?origin=vmd",
67 | "zipcode": "85340"},
68 |
69 | {"adress_city": "SAINT-MEDARD-EN-JALLES",
70 | "adress_street": "7 Place de la Liberté",
71 | "booking_disabled": false,
72 | "center_type": "pharmacie",
73 | "id": "1713",
74 | "name": "Pharmacie de la Liberté",
75 | "opening_hours": [{"day": 1,
76 | "ranges": [["08:30", "12:30"], ["14:00", "20:00"]]},
77 | {"day": 2,
78 | "ranges": [["08:30", "12:30"], ["14:00", "20:00"]]},
79 | {"day": 3,
80 | "ranges": [["08:30", "12:30"], ["14:00", "20:00"]]},
81 | {"day": 4,
82 | "ranges": [["08:30", "12:30"], ["14:00", "20:00"]]},
83 | {"day": 5,
84 | "ranges": [["08:30", "12:30"], ["14:00", "20:00"]]},
85 | {"day": 6,
86 | "ranges": [["09:00", "12:30"], ["14:00", "19:30"]]},
87 | {"day": 7, "ranges": null}],
88 | "phone_number": "+33556050421",
89 | "platform_is": "mesoigner",
90 | "position": {"latitude": "44.88846600", "longitude": "-0.70182240"},
91 | "rdv_site_web": "https://pharmaciedelaliberte.pharmacorp.fr/rendez-vous/vaccination/18-vaccination-covid-19/pre-inscription?origin=vmd",
92 | "zipcode": "33160"}]
--------------------------------------------------------------------------------
/tests/fixtures/mesoigner/slots_available.json:
--------------------------------------------------------------------------------
1 | {"total": 4,
2 | "slots": [
3 | {
4 | "2021-06-16": [
5 | {
6 | "slot_beginning": "2021-06-16T14:50:00+02:00",
7 | "available_vaccines": ["Moderna"],
8 | "number_of_slots": 1
9 | }
10 | ]
11 | },
12 | {"2021-06-17": []},
13 | {"2021-06-185": []},
14 | {
15 | "2021-07-26": [
16 | {
17 | "slot_beginning": "2021-07-26T14:30:00+02:00",
18 | "available_vaccines": ["AstraZeneca"],
19 | "number_of_slots": 1
20 | },
21 | {
22 | "slot_beginning": "2021-07-26T14:55:00+02:00",
23 | "available_vaccines": ["AstraZeneca"],
24 | "number_of_slots": 1
25 | },
26 | {
27 | "slot_beginning": "2021-07-26T16:05:00+02:00",
28 | "available_vaccines": ["Moderna"],
29 | "number_of_slots": 1
30 | }
31 | ]
32 | }
33 | ]
34 | }
35 |
--------------------------------------------------------------------------------
/tests/fixtures/mesoigner/slots_unavailable.json:
--------------------------------------------------------------------------------
1 | {
2 | "total": 0,
3 | "slots": [
4 | {"2021-07-16": []},
5 | {"2021-07-17": []},
6 | {"2021-07-18": []}
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/tests/fixtures/ordoclic/empty_slots.json:
--------------------------------------------------------------------------------
1 | {
2 | "slots": [],
3 | "nextAvailableSlotDate": null
4 | }
--------------------------------------------------------------------------------
/tests/fixtures/ordoclic/fetchslot-profile.json:
--------------------------------------------------------------------------------
1 | {
2 | "profileSlug": "pharmacie-oceane-paris",
3 | "entityId": "03674d71-b200-4682-8e0a-3ab9687b2b59",
4 | "name": "Pharmacie Oceane",
5 | "type": "Pharmacie",
6 | "typeId": 1,
7 | "phone": "+33145788618",
8 | "address": "19 rue Lourmel ",
9 | "city": "Paris",
10 | "zip": "75015",
11 | "listAddress": [{
12 | "id": "b7fd84f8-86d9-4bbe-b8f0-93b2191e7a72",
13 | "lat": 48.84922659999999,
14 | "lng": 2.2914519,
15 | "address": "19 rue Lourmel ",
16 | "city": "Paris",
17 | "zip": "75015",
18 | "createdAt": "2021-03-17T14:00:01.406087Z",
19 | "lastUpdate": "2021-04-14T08:29:23.138829Z",
20 | "activated": true,
21 | "isMainAddress": true
22 | }],
23 | "seoPublishedAt": "2021-03-17T14:00:19.998659Z",
24 | "imagesIds": [],
25 | "timeBlocks": [{
26 | "id": 6422,
27 | "day": "1",
28 | "opening": "08:00:00",
29 | "closing": "20:00:00",
30 | "isFullDay": false
31 | }, {
32 | "id": 6423,
33 | "day": "2",
34 | "opening": "08:00:00",
35 | "closing": "20:00:00",
36 | "isFullDay": false
37 | }, {
38 | "id": 6424,
39 | "day": "3",
40 | "opening": "08:00:00",
41 | "closing": "20:00:00",
42 | "isFullDay": false
43 | }, {
44 | "id": 6425,
45 | "day": "4",
46 | "opening": "08:00:00",
47 | "closing": "20:00:00",
48 | "isFullDay": false
49 | }, {
50 | "id": 6426,
51 | "day": "5",
52 | "opening": "08:00:00",
53 | "closing": "20:00:00",
54 | "isFullDay": false
55 | }, {
56 | "id": 6427,
57 | "day": "6",
58 | "opening": "08:00:00",
59 | "closing": "20:00:00",
60 | "isFullDay": false
61 | }, {
62 | "id": 6428,
63 | "day": "7",
64 | "opening": "09:00:00",
65 | "closing": "19:00:00",
66 | "isFullDay": false
67 | }],
68 | "publicProfessionals": [{
69 | "id": "5c8fc562-d836-4aed-84d6-225e5af8075e",
70 | "fullName": "Laurent HALWANI",
71 | "firstName": "Laurent",
72 | "lastName": "HALWANI",
73 | "title": "Monsieur",
74 | "jobId": 1,
75 | "job": "Pharmacien",
76 | "specialty": "",
77 | "address": "",
78 | "city": "",
79 | "zip": "",
80 | "activatedAt": "2021-03-11T10:11:48.656562Z",
81 | "entities": "Pharmacie Oceane",
82 | "publicProfileSlug": "laurent-halwani"
83 | }],
84 | "attributeValues": [{
85 | "label": "introduction",
86 | "value": "/!\\ Vaccin actuellement disponible : ASTRAZENECA /!\\ \nNOTE IMPORTANTE : La Pharmacie se réserve le droit de modifier ou d'annuler les rendez-vous selon les disponibilités d'approvisionnement\n \n"
87 | }, {
88 | "label": "service_price",
89 | "value": []
90 | }, {
91 | "label": "keywords",
92 | "value": ["Rendez-Vous", "Covid-19"]
93 | }, {
94 | "label": "public_pro",
95 | "value": ["5c8fc562-d836-4aed-84d6-225e5af8075e"]
96 | }, {
97 | "label": "waiting_room_link",
98 | "value": {
99 | "url": "",
100 | "enabled": true
101 | }
102 | }, {
103 | "label": "booking_settings",
104 | "value": {
105 | "option": "internal"
106 | }
107 | }],
108 | "showPublicProfileDocument": true,
109 | "partnerId": "b0d54363-04ce-4c43-88d4-7f61889f6c02",
110 | "lobbyRoom": {
111 | "id": "615d47f8-108b-41bf-82e4-0dfd10c6a511",
112 | "entityId": "03674d71-b200-4682-8e0a-3ab9687b2b59",
113 | "accessCode": "LDFK2GLKM6",
114 | "activatedAt": null,
115 | "entityAdminActivatedAt": null,
116 | "insuranceRequirementId": 2,
117 | "reasonRequirementId": 3,
118 | "paymentMean": false
119 | }
120 | }
--------------------------------------------------------------------------------
/tests/fixtures/ordoclic/fetchslot-profile2.json:
--------------------------------------------------------------------------------
1 | {
2 | "profileSlug": "pharmacie-oceane-paris",
3 | "entityId": "03674d71-b200-4682-8e0a-3ab9687b2b59",
4 | "name": "Pharmacie Oceane",
5 | "type": "Pharmacie",
6 | "typeId": 1,
7 | "phone": "+33145788618",
8 | "address": "19 rue Lourmel ",
9 | "city": "Paris",
10 | "zip": "75015",
11 | "listAddress": [{
12 | "id": "b7fd84f8-86d9-4bbe-b8f0-93b2191e7a72",
13 | "lat": 48.84922659999999,
14 | "lng": 2.2914519,
15 | "address": "19 rue Lourmel ",
16 | "city": "Paris",
17 | "zip": "75015",
18 | "createdAt": "2021-03-17T14:00:01.406087Z",
19 | "lastUpdate": "2021-04-14T08:29:23.138829Z",
20 | "activated": true,
21 | "isMainAddress": true
22 | }],
23 | "seoPublishedAt": "2021-03-17T14:00:19.998659Z",
24 | "imagesIds": [],
25 | "timeBlocks": [{
26 | "id": 6422,
27 | "day": "1",
28 | "opening": "08:00:00",
29 | "closing": "20:00:00",
30 | "isFullDay": false
31 | }, {
32 | "id": 6423,
33 | "day": "2",
34 | "opening": "08:00:00",
35 | "closing": "20:00:00",
36 | "isFullDay": false
37 | }, {
38 | "id": 6424,
39 | "day": "3",
40 | "opening": "08:00:00",
41 | "closing": "20:00:00",
42 | "isFullDay": false
43 | }, {
44 | "id": 6425,
45 | "day": "4",
46 | "opening": "08:00:00",
47 | "closing": "20:00:00",
48 | "isFullDay": false
49 | }, {
50 | "id": 6426,
51 | "day": "5",
52 | "opening": "08:00:00",
53 | "closing": "20:00:00",
54 | "isFullDay": false
55 | }, {
56 | "id": 6427,
57 | "day": "6",
58 | "opening": "08:00:00",
59 | "closing": "20:00:00",
60 | "isFullDay": false
61 | }, {
62 | "id": 6428,
63 | "day": "7",
64 | "opening": "09:00:00",
65 | "closing": "19:00:00",
66 | "isFullDay": false
67 | }],
68 | "publicProfessionals": [{
69 | "id": "5c8fc562-d836-4aed-84d6-225e5af8075e",
70 | "fullName": "Laurent HALWANI",
71 | "firstName": "Laurent",
72 | "lastName": "HALWANI",
73 | "title": "Monsieur",
74 | "jobId": 1,
75 | "job": "Pharmacien",
76 | "specialty": "",
77 | "address": "",
78 | "city": "",
79 | "zip": "",
80 | "activatedAt": "2021-03-11T10:11:48.656562Z",
81 | "entities": "Pharmacie Oceane",
82 | "publicProfileSlug": "laurent-halwani"
83 | }],
84 | "attributeValues": [{
85 | "label": "introduction",
86 | "value": "/!\\ Vaccin actuellement disponible : ASTRAZENECA /!\\ \nNOTE IMPORTANTE : La Pharmacie se réserve le droit de modifier ou d'annuler les rendez-vous selon les disponibilités d'approvisionnement\n \n"
87 | }, {
88 | "label": "service_price",
89 | "value": []
90 | }, {
91 | "label": "keywords",
92 | "value": ["Rendez-Vous", "Covid-19"]
93 | }, {
94 | "label": "public_pro",
95 | "value": ["5c8fc562-d836-4aed-84d6-225e5af8075e"]
96 | }, {
97 | "label": "waiting_room_link",
98 | "value": {
99 | "url": "",
100 | "enabled": true
101 | }
102 | }, {
103 | "label": "booking_settings",
104 | "value": {
105 | "option": "any"
106 | }
107 | }],
108 | "showPublicProfileDocument": true,
109 | "partnerId": "b0d54363-04ce-4c43-88d4-7f61889f6c02",
110 | "lobbyRoom": {
111 | "id": "615d47f8-108b-41bf-82e4-0dfd10c6a511",
112 | "entityId": "03674d71-b200-4682-8e0a-3ab9687b2b59",
113 | "accessCode": "LDFK2GLKM6",
114 | "activatedAt": null,
115 | "entityAdminActivatedAt": null,
116 | "insuranceRequirementId": 2,
117 | "reasonRequirementId": 3,
118 | "paymentMean": false
119 | }
120 | }
--------------------------------------------------------------------------------
/tests/fixtures/ordoclic/nextavailable_slots.json:
--------------------------------------------------------------------------------
1 | {
2 | "slots": [],
3 | "nextAvailableSlotDate": "2021-06-12T10:30:00Z"
4 | }
--------------------------------------------------------------------------------
/tests/fixtures/ordoclic/reasons.schema:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-04/schema#",
3 | "type": "object",
4 | "properties": {
5 | "reasons": {
6 | "type": "array",
7 | "items": {
8 | "type": "object",
9 | "properties": {
10 | "id": {
11 | "type": "string"
12 | },
13 | "name": {
14 | "type": "string"
15 | },
16 | "duration": {
17 | "type": "integer"
18 | },
19 | "color": {
20 | "type": "string"
21 | },
22 | "canBookOnline": {
23 | "type": "boolean"
24 | },
25 | "professionalId": {
26 | "type": "string"
27 | },
28 | "reasonTypeId": {
29 | "type": "integer"
30 | },
31 | "reasonType": {
32 | "type": "string"
33 | },
34 | "isAcceptNewPatient": {
35 | "type": "boolean"
36 | },
37 | "advice": {
38 | "type": "string"
39 | },
40 | "entityId": {
41 | "type": "null"
42 | },
43 | "isTcsMode": {
44 | "type": "boolean"
45 | },
46 | "defaultDocuments": {
47 | "type": "array",
48 | "items": {
49 | "type": "object",
50 | "properties": {
51 | "documentId": {
52 | "type": "string"
53 | },
54 | "typeId": {
55 | "type": "integer"
56 | },
57 | "favoriteLabel": {
58 | "type": "string"
59 | },
60 | "permanentAccessCode": {
61 | "type": "string"
62 | },
63 | "notifiedAt": {
64 | "type": "null"
65 | },
66 | "reasonId": {
67 | "type": "null"
68 | },
69 | "documentNotificationCondition": {
70 | "type": "integer"
71 | },
72 | "documentNotificationTimeValueInHours": {
73 | "type": "integer"
74 | },
75 | "multiFormType": {
76 | "type": "string"
77 | },
78 | "needPatientSignature": {
79 | "type": "boolean"
80 | }
81 | },
82 | "required": [
83 | "documentId",
84 | "typeId",
85 | "favoriteLabel",
86 | "permanentAccessCode",
87 | "notifiedAt",
88 | "reasonId",
89 | "documentNotificationCondition",
90 | "documentNotificationTimeValueInHours",
91 | "multiFormType",
92 | "needPatientSignature"
93 | ]
94 | }
95 | },
96 | "documentNotificationCondition": {
97 | "type": "integer"
98 | },
99 | "documentNotificationTimeValueInHours": {
100 | "type": "integer"
101 | }
102 | },
103 | "required": [
104 | "id",
105 | "name",
106 | "duration",
107 | "color",
108 | "canBookOnline",
109 | "professionalId",
110 | "reasonTypeId",
111 | "reasonType",
112 | "isAcceptNewPatient",
113 | "advice",
114 | "entityId",
115 | "isTcsMode",
116 | "defaultDocuments",
117 | "documentNotificationCondition",
118 | "documentNotificationTimeValueInHours"
119 | ]
120 | }
121 | },
122 | "limitedMode": {
123 | "type": "boolean"
124 | }
125 | },
126 | "required": [
127 | "reasons",
128 | "limitedMode"
129 | ]
130 | }
131 |
--------------------------------------------------------------------------------
/tests/fixtures/utils/info_centres.json:
--------------------------------------------------------------------------------
1 | {
2 | "01": {
3 | "version": 1,
4 | "last_updated": "2021-04-16T18:44:43.303624+02:00",
5 | "centres_disponibles": [
6 | {
7 | "departement": "01",
8 | "nom": "Centre 1",
9 | "url": "https://example1.fr",
10 | "location": {
11 | "longitude": 5.601759,
12 | "latitude": 45.977509,
13 | "city": "Plateau d'Hauteville"
14 | },
15 | "metadata": {
16 | "address": "Rue de la R\u00e9publique, 01110 Plateau d'Hauteville",
17 | "business_hours": {
18 | "lundi": null,
19 | "mardi": null,
20 | "mercredi": null,
21 | "jeudi": null,
22 | "vendredi": null,
23 | "samedi": null,
24 | "dimanche": null
25 | }
26 | },
27 | "prochain_rdv": "2021-05-14T12:30:00.000+02:00",
28 | "plateforme": "Doctolib",
29 | "type": "vaccination-center",
30 | "appointment_count": 35,
31 | "internal_id": "264639[]",
32 | "vaccine_type": [
33 | "Pfizer-BioNTech"
34 | ],
35 | "erreur": null,
36 | "gid": "d264639",
37 | "last_scan_with_availabilities": "2021-04-04T00:00:00",
38 | "appointment_schedules": [
39 | {
40 | "name": "chronodose",
41 | "from": "2021-05-10T00:00:00+02:00",
42 | "to": "2021-05-11T23:59:59+02:00",
43 | "total": 0
44 | },
45 | {
46 | "name": "1_days",
47 | "from": "2021-05-10T00:00:00+02:00",
48 | "to": "2021-05-10T23:59:59+02:00",
49 | "total": 0
50 | },
51 | {
52 | "name": "2_days",
53 | "from": "2021-05-10T00:00:00+02:00",
54 | "to": "2021-05-11T23:59:59+02:00",
55 | "total": 7
56 | },
57 | {
58 | "name": "7_days",
59 | "from": "2021-05-10T00:00:00+02:00",
60 | "to": "2021-05-16T23:59:59+02:00",
61 | "total": 7
62 | },
63 | {
64 | "name": "28_days",
65 | "from": "2021-05-10T00:00:00+02:00",
66 | "to": "2021-06-06T23:59:59+02:00",
67 | "total": 7
68 | },
69 | {
70 | "name": "49_days",
71 | "from": "2021-05-10T00:00:00+02:00",
72 | "to": "2021-06-27T23:59:59+02:00",
73 | "total": 7
74 | }
75 | ]
76 | }
77 | ],
78 | "centres_indisponibles": [
79 | {
80 | "departement": "01",
81 | "nom": "Centre 2",
82 | "url": "https://example2.fr",
83 | "location": {
84 | "longitude": 5.606802999999999,
85 | "latitude": 46.153488,
86 | "city": "nantua"
87 | },
88 | "metadata": {
89 | "address": "19 rue du Coll\u00e8ge, 01130 Nantua",
90 | "phone_number": "+33474750042",
91 | "business_hours": null
92 | },
93 | "prochain_rdv": "2021-04-19T09:00:00+00:00",
94 | "plateforme": "Ordoclic",
95 | "type": "drugstore",
96 | "appointment_count": 0,
97 | "internal_id": null,
98 | "vaccine_type": null,
99 | "erreur": null,
100 | "gid": "feb094ba",
101 | "last_scan_with_availabilities": null
102 | },
103 | {
104 | "departement": "01",
105 | "nom": "Centre 3",
106 | "url": "https://example3.fr",
107 | "location": {
108 | "longitude": 5.606802999999999,
109 | "latitude": 46.153488,
110 | "city": "nantua"
111 | },
112 | "metadata": {
113 | "address": "19 rue du Coll\u00e8ge, 01130 Nantua",
114 | "phone_number": "+33474750042",
115 | "business_hours": null
116 | },
117 | "prochain_rdv": "2021-04-19T09:00:00+00:00",
118 | "plateforme": "Ordoclic",
119 | "type": "drugstore",
120 | "appointment_count": 46,
121 | "internal_id": null,
122 | "vaccine_type": null,
123 | "erreur": null,
124 | "gid": "feb094ba",
125 | "last_scan_with_availabilities": "2021-03-03T00:00:00"
126 | }
127 | ]
128 | }
129 | }
--------------------------------------------------------------------------------
/tests/fixtures/valwin/slots_unavailable.json:
--------------------------------------------------------------------------------
1 | {"links":{"next":null,"total":0,"prev":null,"pageSize":60,"currentPage":1,"nbPages":0},"result":[]}
--------------------------------------------------------------------------------
/tests/fixtures/valwin/valwin_center_info.json:
--------------------------------------------------------------------------------
1 | {
2 | "departement": "69",
3 | "nom": "Grande Pharmacie du Plateau",
4 | "url": "https://grandepharmacie-du-plateau-lyon.pharmabest.com",
5 | "location": {
6 | "longitude": 4.795371,
7 | "latitude": 45.786598,
8 | "city": "Lyon",
9 | "cp": "69009"
10 | },
11 | "metadata": {
12 | "address": "7, place Abb\u00e9 Pierre, 69009 Lyon",
13 | "business_hours": null
14 | },
15 | "prochain_rdv": null,
16 | "plateforme": "Valwin",
17 | "type": "drugstore",
18 | "appointment_count": 0,
19 | "internal_id": "Valwinpharmabest75-plateau-lyon",
20 | "vaccine_type": [],
21 | "appointment_by_phone_only": false,
22 | "erreur": null,
23 | "last_scan_with_availabilities": null,
24 | "request_counts": null
25 | }
--------------------------------------------------------------------------------
/tests/fixtures/valwin/valwin_centers.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "departement": "28",
4 | "nom": "Pharmacie de Combray",
5 | "url": "https://pharmaciedecombray.epharmacie.pro/animation-details/a77026ad-050d-4885-97db-1939ff32cce7/1/60",
6 | "location": {
7 | "longitude": 1.25814,
8 | "latitude": 48.3043,
9 | "city": "Illiers-Combray",
10 | "cp": "28120"
11 | },
12 | "metadata": {
13 | "address": "Avenue Marcel Proust, 28120 Illiers-Combray",
14 | "business_hours": null
15 | },
16 | "prochain_rdv": "2021-09-14T17:05:00",
17 | "plateforme": "Valwin",
18 | "type": "drugstore",
19 | "appointment_count": 12,
20 | "internal_id": "Valwinreseausante46-combray",
21 | "vaccine_type": [
22 | "Moderna"
23 | ],
24 | "appointment_by_phone_only": false,
25 | "erreur": null,
26 | "last_scan_with_availabilities": null,
27 | "request_counts": null
28 | },
29 | {
30 | "departement": "51",
31 | "nom": "Pharmacie de Reims",
32 | "url": "https://pharmacie-de-reims.fr/animation-details/a77026ad-050d-4885-97db-1939ff32cce7/1/60",
33 | "location": {
34 | "longitude": 4.024,
35 | "latitude": 49.267829,
36 | "city": "Reims",
37 | "cp": "51100"
38 | },
39 | "metadata": {
40 | "address": "153 - 155, avenue de Laon, 51100 Reims",
41 | "business_hours": null
42 | },
43 | "prochain_rdv": "2021-09-15T13:00:00",
44 | "plateforme": "Valwin",
45 | "type": "drugstore",
46 | "appointment_count": 9,
47 | "internal_id": "Valwinph34-reims",
48 | "vaccine_type": [
49 | "Moderna"
50 | ],
51 | "appointment_by_phone_only": false,
52 | "erreur": null,
53 | "last_scan_with_availabilities": null,
54 | "request_counts": null
55 | },
56 | {
57 | "departement": "95",
58 | "nom": "Pharmacie du Château",
59 | "url": "https://pharmacie-beaumontsuroise.com/animation-details/a77026ad-050d-4885-97db-1939ff32cce7/1/60",
60 | "location": {
61 | "longitude": 2.28665,
62 | "latitude": 49.142929,
63 | "city": "Beaumont-sur-Oise",
64 | "cp": "95260"
65 | },
66 | "metadata": {
67 | "address": "2, rue Albert 1er, 95260 Beaumont-sur-Oise",
68 | "business_hours": null
69 | },
70 | "prochain_rdv": "2021-09-16T18:10:00",
71 | "plateforme": "Valwin",
72 | "type": "drugstore",
73 | "appointment_count": 26,
74 | "internal_id": "Valwinph54-chateau-beaumont-oise",
75 | "vaccine_type": [
76 | "Moderna"
77 | ],
78 | "appointment_by_phone_only": false,
79 | "erreur": null,
80 | "last_scan_with_availabilities": null,
81 | "request_counts": null
82 | }
83 | ]
--------------------------------------------------------------------------------
/tests/stats_generation/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/tests/stats_generation/__init__.py
--------------------------------------------------------------------------------
/tests/stats_generation/test_by_vaccine.py:
--------------------------------------------------------------------------------
1 | from functools import reduce
2 | from stats_generation import by_vaccine
3 |
4 |
5 | def test_merge():
6 | data = {}
7 | by_vaccine.merge(data, ("Astra", 10))
8 | assert data == {"Astra": 10}
9 |
10 | by_vaccine.merge(data, ("Astra", 10))
11 | assert data == {"Astra": 20}
12 |
13 | by_vaccine.merge(data, ("Pfizer", 100))
14 | assert data == {"Astra": 20, "Pfizer": 100}
15 |
16 |
17 | def test_flatten_vaccine_types():
18 | data = {
19 | "version": 1,
20 | "last_updated": "2021-07-19T23:02:28+02:00",
21 | "centres_disponibles": [
22 | {
23 | "departement": "75",
24 | "nom": "Pharmacie Bassereau",
25 | "url": "https://www.maiia.com/pharmacie/75017-paris/pharmacie-bassereau?centerid=604b186665d8f5139d42dc21",
26 | "location": {"longitude": 2.317913, "latitude": 48.886224, "city": "Paris", "cp": "75017"},
27 | "metadata": {
28 | "address": "70 Rue Legendre 75017 Paris",
29 | "business_hours": {
30 | "Lundi": "09:20-20:00",
31 | "Mardi": "09:20-20:00",
32 | "Mercredi": "09:20-20:00",
33 | "Jeudi": "09:20-20:00",
34 | "Vendredi": "09:20-20:00",
35 | "Samedi": "09:20-20:00",
36 | "Dimanche": "09:20-20:00",
37 | },
38 | },
39 | "prochain_rdv": "2021-07-20T07:00:00+00:00",
40 | "plateforme": "Maiia",
41 | "type": "drugstore",
42 | "appointment_count": 7,
43 | "internal_id": "maiia604b1866",
44 | "vaccine_type": ["Janssen"],
45 | "appointment_by_phone_only": False,
46 | "erreur": None,
47 | "last_scan_with_availabilities": None,
48 | "request_counts": None,
49 | },
50 | {
51 | "departement": "94",
52 | "nom": "Centre de vaccination - CHI de Villeneuve-Saint-Georges",
53 | "url": "https://www.maiia.com/centre-de-vaccination/94190-villeneuve-saint-georges/centre-de-vaccination---chi-de-villeneuve-saint-georges?centerid=6001704008fa3a60d6d1b0aa",
54 | "location": {
55 | "longitude": 2.450239,
56 | "latitude": 48.723306,
57 | "city": "Villeneuve-Saint-Georges",
58 | "cp": "94190",
59 | },
60 | "metadata": {
61 | "address": "40 Allée de la Source 94190 Villeneuve-Saint-Georges",
62 | "business_hours": {
63 | "Lundi": "09:00-16:30",
64 | "Mardi": "09:00-16:30",
65 | "Mercredi": "09:00-16:30",
66 | "Jeudi": "09:00-16:30",
67 | "Vendredi": "09:00-16:30",
68 | "Samedi": "09:00-16:30",
69 | "Dimanche": "09:00-16:30",
70 | },
71 | "phone_number": "+33143862150",
72 | },
73 | "prochain_rdv": "2021-07-20T07:10:00+00:00",
74 | "plateforme": "Maiia",
75 | "type": "vaccination-center",
76 | "appointment_count": 650,
77 | "internal_id": "maiia60017040",
78 | "vaccine_type": ["Pfizer-BioNTech"],
79 | "appointment_by_phone_only": False,
80 | "erreur": None,
81 | "last_scan_with_availabilities": None,
82 | "request_counts": None,
83 | },
84 | ],
85 | }
86 | flattened = list(by_vaccine.flatten_vaccine_types_schedules(data))
87 | assert flattened == [("Janssen", 1), ("Pfizer-BioNTech", 1)]
88 | assert reduce(by_vaccine.merge, flattened, {}) == {"Janssen": 1, "Pfizer-BioNTech": 1}
89 |
--------------------------------------------------------------------------------
/tests/test_center_location.py:
--------------------------------------------------------------------------------
1 | from scraper.pattern.center_location import convert_csv_data_to_location
2 |
3 |
4 | def test_location_working():
5 | dict = {"long_coor1": 1.231, "lat_coor1": -42.839, "com_nom": "Rennes"}
6 | center_location = convert_csv_data_to_location(dict)
7 | assert center_location
8 | assert center_location.longitude == 1.231
9 | assert center_location.latitude == -42.839
10 | assert center_location.city == "Rennes"
11 |
12 |
13 | def test_location_issue():
14 | dict = {"long_coor31": 1.231, "lat_coor13": -42.839, "com_nom": "Rennes"}
15 | center_location = convert_csv_data_to_location(dict)
16 | assert center_location is None
17 |
18 |
19 | def test_location_parse_address():
20 | dict = {"long_coor1": 1.231, "lat_coor1": -42.839, "address": "39 Rue de la Fraise, 35000 Foobar"}
21 | center_location = convert_csv_data_to_location(dict)
22 | assert center_location.city == "Foobar"
23 |
24 |
25 | def test_location_bad_values():
26 | dict = {"long_coor1": "1,231Foo", "lat_coor1": -1.23, "address": "39 Rue de la Fraise, 35000 Foobar"}
27 | center_location = convert_csv_data_to_location(dict)
28 | assert center_location is None
29 |
30 |
31 | def test_location_callback():
32 | dict = {"long_coor1": "1.231", "lat_coor1": -1.23, "address": "39 Rue de la Fraise, 35000 Foo2bar"}
33 | center_location = convert_csv_data_to_location(dict)
34 | assert center_location.default() == {"longitude": 1.231, "latitude": -1.23, "city": "Foo2bar", "cp": "35000"}
35 |
--------------------------------------------------------------------------------
/tests/test_geo_api.py:
--------------------------------------------------------------------------------
1 | from utils.vmd_geo_api import get_location_from_address, get_location_from_coordinates, Location, Coordinates
2 |
3 | location1: Location = {
4 | "full_address": "389 avenue mal de lattre de tassigny 71000 Mâcon",
5 | "number_street": "389 avenue mal de lattre de tassigny",
6 | "com_name": "Mâcon",
7 | "com_zipcode": "71000",
8 | "com_insee": "71270",
9 | "departement": "71",
10 | "longitude": 4.840267,
11 | "latitude": 46.316225,
12 | }
13 |
14 |
15 | location2: Location = {
16 | "full_address": "4 Rue des Hibiscus 97200 Fort-de-France",
17 | "number_street": "4 Rue des Hibiscus",
18 | "com_name": "Fort-de-France",
19 | "com_zipcode": "97200",
20 | "com_insee": "97209",
21 | "departement": "972",
22 | "longitude": -61.078206,
23 | "latitude": 14.611228,
24 | }
25 |
26 |
27 | location3: Location = {
28 | "full_address": "Rue du Grand But (Lomme) 59000 Lille",
29 | "number_street": "Rue du Grand But (Lomme)",
30 | "com_name": "Lille",
31 | "com_zipcode": "59000",
32 | "com_insee": "59350",
33 | "departement": "59",
34 | "longitude": 2.974304,
35 | "latitude": 50.649991,
36 | }
37 |
38 |
39 | # This test actually calls to the API Adresse via Internet
40 | # Slight updates in their result might break the assertion
41 | # while not being an actual problem
42 | # it's not that frequent
43 |
44 | def test_get_location_from_address():
45 | # Common address
46 | address: str = "389 Avenue Maréchal de Lattre de Tassigny"
47 | inseecode: str = "71270" # Varennes-lès-Mâcon
48 | zipcode: str = "71000"
49 |
50 | assert get_location_from_address(address) != location1 # Too generic, can't find with more input
51 | assert get_location_from_address(address, zipcode=zipcode) == location1
52 | assert get_location_from_address(address, inseecode=inseecode) == location1
53 |
54 | # Specific address, in DOM-TOM
55 | address: str = "4 rue des Hibiscus\n97200 Fort-de-France"
56 | assert get_location_from_address(address) == location2
57 |
58 | # Specific address with CEDEX code
59 | address: str = "Rue du Grand But - BP 249, 59462Cedex Lomme"
60 | cedexcode: str = "59462"
61 | inseecode: str = "59350"
62 |
63 | assert get_location_from_address(address) == location3
64 | assert get_location_from_address(address, zipcode=cedexcode) == None # API Adresse does not handle CEDEX codes
65 | assert get_location_from_address(address, inseecode=inseecode) == location3
66 |
67 | # Check cache mechanism
68 | get_location_from_address.cache_clear()
69 | get_location_from_address(address)
70 | get_location_from_address(address)
71 | assert get_location_from_address.cache_info().hits == 1
72 | assert get_location_from_address.cache_info().misses == 1
73 |
74 |
75 | def test_get_location_from_coordinates():
76 | coordinates: Coordinates = Coordinates(4.8405438, 46.3165338)
77 |
78 | assert get_location_from_coordinates(coordinates) == location1
79 |
80 | # Check cache mechanism
81 | get_location_from_coordinates.cache_clear()
82 | get_location_from_coordinates(coordinates)
83 | get_location_from_coordinates(coordinates)
84 | assert get_location_from_coordinates.cache_info().hits == 1
85 | assert get_location_from_coordinates.cache_info().misses == 1
86 |
--------------------------------------------------------------------------------
/tests/test_keldoc_center_scrap.py:
--------------------------------------------------------------------------------
1 | import httpx
2 | from scraper.keldoc.keldoc_center_scrap import parse_keldoc_resource_url, KeldocCenterScraper
3 | from tests.test_keldoc import get_test_data
4 |
5 | TEST_CENTERS = [
6 | {
7 | "item": {
8 | "url": "https://keldoc.com/centre-de-vaccination/62800-lievin/centre-de-vaccination-lievin-pays-dartois"
9 | },
10 | "result": "https://booking.keldoc.com/api/patients/v2/searches/resource?type=centre-de-vaccination&location=62800-lievin&slug=centre-de-vaccination-lievin-pays-dartois",
11 | },
12 | {
13 | "item": {
14 | "url": "https://www.keldoc.com/centre-hospitalier/melun-cedex-77011/groupe-hospitalier-sud-ile-de-france/centre-de-vaccination-ghsif-site-de-brie-comte-robert"
15 | },
16 | "result": "https://booking.keldoc.com/api/patients/v2/searches/resource?type=centre-hospitalier&location=melun-cedex-77011&slug=groupe-hospitalier-sud-ile-de-france&cabinet=centre-de-vaccination-ghsif-site-de-brie-comte-robert",
17 | },
18 | ]
19 |
20 | API_MOCKS = {
21 | "/api/patients/v2/searches/resource": "resource-ain",
22 | "/api/patients/v2/searches/geo_location": "department-ain",
23 | "/api/patients/v2/clinics/2737/specialties/144/cabinets/17136/motive_categories": "motives-ain",
24 | }
25 |
26 |
27 | def test_keldoc_center_scraper():
28 | def app(request: httpx.Request) -> httpx.Response:
29 | if request.url.path in API_MOCKS:
30 | return httpx.Response(200, json=get_test_data(API_MOCKS[request.url.path]))
31 | return httpx.Response(200, json={})
32 |
33 | client = httpx.Client(transport=httpx.MockTransport(app))
34 | print(client._base_url)
35 | scraper = KeldocCenterScraper(session=client)
36 | result = scraper.run_departement_scrap("ain")
37 | print(result)
38 | assert result == get_test_data("result-ain")
39 |
40 |
41 | def test_parse_keldoc_resource_url():
42 | for test_center in TEST_CENTERS:
43 | assert parse_keldoc_resource_url(test_center["item"]["url"]) == test_center["result"]
44 |
45 |
46 | def test_keldoc_requests():
47 | # Timeout test
48 | def app_timeout(request: httpx.Request) -> httpx.Response:
49 | raise httpx.TimeoutException(message="Timeout", request=request)
50 |
51 | client = httpx.Client(transport=httpx.MockTransport(app_timeout))
52 | scraper = KeldocCenterScraper(session=client)
53 | assert not scraper.send_keldoc_request("https://keldoc.com")
54 |
55 | # Status test
56 | def app_status(request: httpx.Request) -> httpx.Response:
57 | res = httpx.Response(403, json={})
58 | raise httpx.HTTPStatusError(message="status error", request=request, response=res)
59 |
60 | client = httpx.Client(transport=httpx.MockTransport(app_status))
61 | scraper = KeldocCenterScraper(session=client)
62 | assert not scraper.send_keldoc_request("https://keldoc.com")
63 |
64 | # Remote error test
65 | def app_remote_error(request: httpx.Request) -> httpx.Response:
66 | res = httpx.Response(403, json={})
67 | raise httpx.RemoteProtocolError(message="status error", request=request)
68 |
69 | client = httpx.Client(transport=httpx.MockTransport(app_remote_error))
70 | scraper = KeldocCenterScraper(session=client)
71 | assert not scraper.send_keldoc_request("https://keldoc.com")
72 |
--------------------------------------------------------------------------------
/tests/test_mesoigner.py:
--------------------------------------------------------------------------------
1 | import json
2 | from scraper.pattern.scraper_request import ScraperRequest
3 | from scraper.pattern.center_location import CenterLocation
4 | from scraper.pattern.center_info import CenterInfo
5 | import httpx
6 | from pathlib import Path
7 | from jsonschema import validate
8 | from jsonschema.exceptions import ValidationError
9 | from datetime import datetime
10 | from dateutil.tz import tzutc
11 | import io
12 | import scraper.mesoigner.mesoigner as mesoigner
13 | from scraper.pattern.vaccine import Vaccine
14 | from utils.vmd_config import get_conf_platform
15 |
16 |
17 | MESOIGNER_CONF = get_conf_platform("mesoigner")
18 | MESOIGNER_APIs = MESOIGNER_CONF.get("api", "")
19 |
20 |
21 | TEST_CENTRE_INFO = Path("tests", "fixtures", "mesoigner", "mesoigner_center_info.json")
22 |
23 |
24 | def test_get_appointments():
25 |
26 | """get_appointments should return first available appointment date"""
27 |
28 | center_data = dict()
29 | center_data = json.load(io.open(TEST_CENTRE_INFO, "r", encoding="utf-8-sig"))
30 |
31 | # This center has availabilities and should return a date, non null appointment_count and vaccines
32 | request = ScraperRequest(
33 | "https://pharmacie-des-pyrenees.pharmaxv.fr/rendez-vous/vaccination/269-vaccination-covid-19/pre-inscription",
34 | "2021-06-16",
35 | center_data,
36 | )
37 | center_with_availability = mesoigner.MesoignerSlots()
38 | slots = json.load(
39 | io.open(Path("tests", "fixtures", "mesoigner", "slots_available.json"), "r", encoding="utf-8-sig")
40 | )
41 | assert center_with_availability.get_appointments(request, slots_api=slots) == "2021-06-16T14:50:00+02:00"
42 | assert request.appointment_count == 4
43 | assert request.vaccine_type == [Vaccine.MODERNA, Vaccine.ASTRAZENECA]
44 |
45 | # This one should return no date, neither appointment_count nor vaccine.
46 | request = ScraperRequest(
47 | "https://pharmacie-des-pyrenees.pharmaxv.fr/rendez-vous/vaccination/269-vaccination-covid-19/pre-inscription",
48 | "2021-07-16",
49 | center_data,
50 | )
51 | center_without_availability = mesoigner.MesoignerSlots()
52 | slots = json.load(
53 | io.open(Path("tests", "fixtures", "mesoigner", "slots_unavailable.json"), "r", encoding="utf-8-sig")
54 | )
55 | assert center_without_availability.get_appointments(request, slots_api=slots) == None
56 | assert request.appointment_count == 0
57 | assert request.vaccine_type == None
58 |
59 |
60 | from unittest.mock import patch
61 |
62 | # On se place dans le cas où la plateforme est désactivée
63 | def test_fetch_slots():
64 | mesoigner.PLATFORM_ENABLED = False
65 | center_data = dict()
66 | center_data = json.load(io.open(TEST_CENTRE_INFO, "r", encoding="utf-8-sig"))
67 |
68 | request = ScraperRequest(
69 | "https://pharmacie-des-pyrenees.pharmaxv.fr/rendez-vous/vaccination/269-vaccination-covid-19/pre-inscription",
70 | "2021-06-16",
71 | center_data,
72 | )
73 | response = mesoigner.fetch_slots(request)
74 |
75 | # On devrait trouver None puisque la plateforme est désactivée
76 | assert response == None
77 |
78 |
79 | def test_fetch():
80 | mesoigner.PLATFORM_ENABLED = True
81 |
82 | center_data = dict()
83 | center_data = json.load(io.open(TEST_CENTRE_INFO, "r", encoding="utf-8-sig"))
84 |
85 | center_info = CenterInfo.from_csv_data(center_data)
86 |
87 | # This center has availabilities and should return a date, non null appointment_count and vaccines
88 | request = ScraperRequest(
89 | "https://pharmacie-des-pyrenees.pharmaxv.fr/rendez-vous/vaccination/269-vaccination-covid-19/pre-inscription",
90 | "2021-06-16",
91 | center_info,
92 | )
93 | slots = json.load(
94 | io.open(Path("tests", "fixtures", "mesoigner", "slots_available.json"), "r", encoding="utf-8-sig")
95 | )
96 |
97 | def app(requested: httpx.Request) -> httpx.Response:
98 | assert "User-Agent" in requested.headers
99 |
100 | return httpx.Response(200, json=slots)
101 |
102 | client = httpx.Client(transport=httpx.MockTransport(app))
103 |
104 | center_with_availability = mesoigner.MesoignerSlots(client=client)
105 |
106 | response = center_with_availability.fetch(request)
107 | assert response == "2021-06-16T14:50:00+02:00"
108 |
109 |
110 | def test_center_iterator():
111 | result = mesoigner.center_iterator()
112 | if mesoigner.PLATFORM_ENABLED == False:
113 | assert result == None
114 |
115 |
116 | def test_center_iterator():
117 | def app(request: httpx.Request) -> httpx.Response:
118 | print(request.url.path)
119 | path = Path("tests/fixtures/mesoigner/mesoigner_centers.json")
120 | return httpx.Response(200, json=json.loads(path.read_text(encoding="utf8")))
121 |
122 | client = httpx.Client(transport=httpx.MockTransport(app))
123 | centres = [centre for centre in mesoigner.center_iterator(client)]
124 | assert len(centres) == 4
125 |
--------------------------------------------------------------------------------
/tests/test_scraper.py:
--------------------------------------------------------------------------------
1 | import datetime as dt
2 | import json
3 |
4 | from scraper.pattern.center_info import CenterInfo
5 | from scraper.pattern.scraper_result import GENERAL_PRACTITIONER, ScraperResult
6 | from scraper.pattern.vaccine import Vaccine, get_vaccine_name
7 | from utils.vmd_utils import departementUtils
8 | from scraper.scraper import fetch_centre_slots
9 | from scraper.pattern.scraper_request import ScraperRequest
10 | from scraper.error import Blocked403
11 | from .utils import mock_datetime_now
12 | from utils.vmd_utils import DummyQueue
13 | from scraper.pattern.center_info import CenterInfo
14 |
15 |
16 | def test_get_vaccine_name():
17 | assert get_vaccine_name("Vaccination Covid -55ans suite à une première injection d'AZ (ARNm)") == Vaccine.ARNM
18 | assert get_vaccine_name("Vaccination ARN suite à une 1ere injection Astra Zeneca") == Vaccine.ARNM
19 | assert (
20 | get_vaccine_name("Vaccination Covid de moins de 55ans (vaccin ARNm) suite à une 1ère injection d'AZ")
21 | == Vaccine.ARNM
22 | )
23 | assert get_vaccine_name("Vaccination Covid +55ans AZ") == Vaccine.ASTRAZENECA
24 | assert get_vaccine_name("Vaccination Covid Pfizer") == Vaccine.PFIZER
25 | assert get_vaccine_name("Vaccination Covid Moderna") == Vaccine.MODERNA
26 |
27 |
28 | def test_fetch_centre_slots():
29 | """
30 | We detect which implementation to use based on the visit URL.
31 | """
32 |
33 | def fake_doctolib_fetch_slots(request: ScraperRequest, sdate, **kwargs):
34 | return "2021-04-04"
35 |
36 | def fake_keldoc_fetch_slots(request: ScraperRequest, sdate, **kwargs):
37 | return "2021-04-05"
38 |
39 | def fake_maiia_fetch_slots(request: ScraperRequest, sdate, **kwargs):
40 | return "2021-04-06"
41 |
42 | fetch_map = {
43 | "Doctolib": {
44 | "urls": ["https://partners.doctolib.fr", "https://www.doctolib.fr"],
45 | "scraper_ptr": fake_doctolib_fetch_slots,
46 | },
47 | "Keldoc": {
48 | "urls": ["https://vaccination-covid.keldoc.com", "https://keldoc.com"],
49 | "scraper_ptr": fake_keldoc_fetch_slots,
50 | },
51 | "Maiia": {"urls": ["https://www.maiia.com"], "scraper_ptr": fake_maiia_fetch_slots},
52 | }
53 |
54 | start_date = "2021-04-03"
55 | center_info = CenterInfo(departement="08", nom="Mon Centre", url="https://some.url/")
56 |
57 | # Doctolib
58 | url = "https://partners.doctolib.fr/blabla"
59 | res = fetch_centre_slots(
60 | url, None, start_date, fetch_map=fetch_map, creneau_q=DummyQueue(), center_info=center_info
61 | )
62 | assert res.platform == "Doctolib"
63 | assert res.next_availability == "2021-04-04"
64 |
65 | # Doctolib (old)
66 | url = "https://www.doctolib.fr/blabla"
67 | res = fetch_centre_slots(
68 | url, None, start_date, fetch_map=fetch_map, creneau_q=DummyQueue(), center_info=center_info
69 | )
70 | assert res.platform == "Doctolib"
71 | assert res.next_availability == "2021-04-04"
72 |
73 | # Keldoc
74 | url = "https://vaccination-covid.keldoc.com/blabla"
75 | res = fetch_centre_slots(
76 | url, None, start_date, fetch_map=fetch_map, creneau_q=DummyQueue(), center_info=center_info
77 | )
78 | assert res.platform == "Keldoc"
79 | assert res.next_availability == "2021-04-05"
80 |
81 | # Maiia
82 | url = "https://www.maiia.com/blabla"
83 | res = fetch_centre_slots(
84 | url, None, start_date, fetch_map=fetch_map, creneau_q=DummyQueue(), center_info=center_info
85 | )
86 | assert res.platform == "Maiia"
87 | assert res.next_availability == "2021-04-06"
88 |
89 | # Default / unknown
90 | url = "https://www.example.com"
91 | res = fetch_centre_slots(
92 | url, None, start_date, fetch_map=fetch_map, creneau_q=DummyQueue(), center_info=center_info
93 | )
94 | assert res.platform == "Autre"
95 | assert res.next_availability is None
96 |
97 |
98 | def test_scraper_request():
99 | request = ScraperRequest("https://doctolib.fr/center/center-test", "2021-04-14")
100 |
101 | request.update_internal_id("d739")
102 | request.update_practitioner_type(GENERAL_PRACTITIONER)
103 | request.update_appointment_count(42)
104 | request.add_vaccine_type(get_vaccine_name("Injection pfizer 1ère dose"))
105 |
106 | assert request is not None
107 | assert request.internal_id == "d739"
108 | assert request.appointment_count == 42
109 | assert request.vaccine_type == [Vaccine.PFIZER]
110 |
111 | result = ScraperResult(request, "Doctolib", "2021-04-14T14:00:00.0000")
112 | assert result.default() == {
113 | "next_availability": "2021-04-14T14:00:00.0000",
114 | "platform": "Doctolib",
115 | "request": request,
116 | }
117 |
--------------------------------------------------------------------------------
/tests/test_stats.py:
--------------------------------------------------------------------------------
1 | # -- Tests des statistiques --
2 | import json
3 | import os
4 |
5 | from pathlib import Path
6 | from stats_generation.stats_available_centers import export_centres_stats
7 |
8 |
9 | def test_stat_count():
10 | output_file_name = "stats_test.json"
11 | center_data = Path("tests", "fixtures", "stats", "info-centres.json")
12 | export_centres_stats(center_data, output_file_name)
13 |
14 | assert os.path.exists(f"{output_file_name}")
15 |
16 | output_file = open(f"{output_file_name}", "r")
17 | generated_content = output_file.read()
18 | output_file.close()
19 |
20 | stats = json.loads(generated_content)
21 | assert stats["tout_departement"]["disponibles"] == 2
22 | assert stats["tout_departement"]["total"] == 4
23 | os.remove(f"{output_file_name}")
24 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | import datetime as dt
2 |
3 | from utils.vmd_utils import format_phone_number, get_last_scans, append_date_days, department_urlify
4 | from .utils import mock_datetime_now
5 | from scraper.pattern.center_info import CenterInfo
6 |
7 |
8 | def test_format_phone_number():
9 |
10 | phone_number = "+331204312"
11 | assert format_phone_number(phone_number) == "+331204312"
12 |
13 | phone_number = "+569492392"
14 | assert format_phone_number(phone_number) == "+569492392"
15 |
16 | phone_number = "0123456789"
17 | assert format_phone_number(phone_number) == "+33123456789"
18 |
19 | phone_number = "01.20.43.12"
20 | assert format_phone_number(phone_number) == "+331204312"
21 |
22 | phone_number = "3975"
23 | assert format_phone_number(phone_number) == "+333975"
24 |
25 | phone_number = "0033146871340"
26 | assert format_phone_number(phone_number) == "+33146871340"
27 |
28 |
29 | def test_get_last_scans():
30 |
31 | center_info1 = CenterInfo("01", "Centre 1", "https://example1.fr")
32 | center_info2 = CenterInfo("01", "Centre 2", "https://example2.fr")
33 |
34 | center_info2.prochain_rdv = "2021-06-06T00:00:00"
35 |
36 | centres_cherchés = [center_info1, center_info2]
37 |
38 | fake_now = dt.datetime(2021, 5, 5)
39 | with mock_datetime_now(fake_now):
40 | centres_cherchés = get_last_scans(centres_cherchés)
41 |
42 | assert centres_cherchés[0].last_scan_with_availabilities == None
43 | assert centres_cherchés[1].last_scan_with_availabilities == "2021-05-05T00:00:00"
44 |
45 |
46 | def test_department_urlify():
47 | url = "FooBar 42"
48 | assert department_urlify(url) == "foobar-42"
49 |
50 |
51 | TEST_DATES = [
52 | {"item": ("2021-04-21", 0), "result": "2021-04-21T00:00:00+02:00"},
53 | {"item": ("2021-04-21", 3), "result": "2021-04-24T00:00:00+02:00"},
54 | ]
55 |
56 |
57 | def test_append_days_date():
58 | for test_date in TEST_DATES:
59 | item = test_date["item"]
60 | assert append_date_days(item[0], item[1]) == test_date["result"]
61 |
--------------------------------------------------------------------------------
/tests/utils.py:
--------------------------------------------------------------------------------
1 | import datetime as dt
2 | from contextlib import contextmanager
3 | from unittest.mock import patch
4 |
5 |
6 | @contextmanager
7 | def mock_datetime_now(now):
8 | class MockedDatetime(dt.datetime):
9 | @classmethod
10 | def now(cls, *args, **kwargs):
11 | return now
12 |
13 | with patch("datetime.datetime", MockedDatetime):
14 | yield
15 |
--------------------------------------------------------------------------------
/utils/vmd_blocklist.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from scraper.pattern.center_info import CenterInfo
4 | from utils.vmd_config import get_conf_inputs
5 |
6 |
7 | def is_in_blocklist(center: CenterInfo, blocklist_urls) -> bool:
8 | return center.url in blocklist_urls
9 |
10 |
11 | def get_blocklist_urls() -> set:
12 | path_blocklist = get_conf_inputs().get("from_main_branch").get("blocklist")
13 | centers_blocklist_urls = set([center["url"] for center in json.load(open(path_blocklist))["centers_not_displayed"]])
14 | return centers_blocklist_urls
15 |
--------------------------------------------------------------------------------
/utils/vmd_center_sort.py:
--------------------------------------------------------------------------------
1 | def sort_center(center: dict) -> str:
2 | return center.get("prochain_rdv", "-") if center else "-"
3 |
--------------------------------------------------------------------------------
/utils/vmd_config.py:
--------------------------------------------------------------------------------
1 | import json
2 | from pathlib import Path
3 | from typing import Optional
4 |
5 | from utils.vmd_logger import get_logger
6 |
7 | CONFIG_DATA = {}
8 |
9 | logger = get_logger()
10 |
11 |
12 | def get_config() -> dict:
13 | global CONFIG_DATA
14 | if not CONFIG_DATA:
15 | try:
16 | CONFIG_DATA = json.loads(Path("config.json").read_text(encoding="utf8"))
17 | except (OSError, ValueError):
18 | logger.exception("Unable to load configuration file.")
19 | return CONFIG_DATA
20 |
21 |
22 | def get_conf_inputs() -> Optional[dict]:
23 | return get_config().get("inputs", {})
24 |
25 |
26 | def get_conf_outputs() -> Optional[dict]:
27 | return get_config().get("outputs", {})
28 |
29 |
30 | def get_conf_outstats() -> Optional[dict]:
31 | return get_conf_outputs().get("stats", {})
32 |
33 |
34 | def get_conf_platform(platform: str) -> dict:
35 | if not get_config().get("platforms"):
36 | logger.error("Unknown ’platforms’ key in configuration file.")
37 | exit(1)
38 | platform_conf = get_config().get("platforms").get(platform)
39 | if not platform_conf:
40 | logger.error(f"Unknown ’{platform}’ platform in configuration file.")
41 | exit(1)
42 | return platform_conf
43 |
--------------------------------------------------------------------------------
/utils/vmd_duplicated.py:
--------------------------------------------------------------------------------
1 | from collections import Counter
2 |
3 | from utils.vmd_utils import departementUtils
4 |
5 |
6 | def deduplicates_names(departement_centers):
7 | """
8 | Removes unique names by appending city name
9 | in par_departement
10 |
11 | see https://github.com/CovidTrackerFr/vitemadose/issues/173
12 | """
13 | deduplicated_centers = []
14 | departement_center_names_count = Counter([center["nom"] for center in departement_centers])
15 | names_to_remove = {
16 | departement for departement in departement_center_names_count if departement_center_names_count[departement] > 1
17 | }
18 |
19 | for center in departement_centers:
20 | if center["nom"] in names_to_remove:
21 | center["nom"] = f"{center['nom']} - {departementUtils.get_city(center['metadata']['address'])}"
22 | deduplicated_centers.append(center)
23 | return deduplicated_centers
24 |
--------------------------------------------------------------------------------
/utils/vmd_geo_api.py:
--------------------------------------------------------------------------------
1 | from typing import TypedDict, Optional, NamedTuple
2 | import requests
3 | from utils.vmd_logger import get_logger
4 | from functools import lru_cache
5 |
6 | logger = get_logger()
7 |
8 |
9 | class Location(TypedDict):
10 | full_address: str
11 | number_street: str
12 | com_name: str
13 | com_zipcode: str
14 | com_insee: str
15 | departement: str
16 | latitude: float
17 | longitude: float
18 |
19 |
20 | class Coordinates(NamedTuple):
21 | longitude: float
22 | latitude: float
23 |
24 |
25 | @lru_cache
26 | def get_location_from_address(
27 | address: str,
28 | zipcode: Optional[str] = None,
29 | inseecode: Optional[str] = None,
30 | ) -> Optional[Location]:
31 | params = {"q": address, "limit": 1}
32 |
33 | if zipcode:
34 | params["postcode"] = zipcode
35 | elif inseecode:
36 | params["citycode"] = inseecode
37 |
38 | r = requests.get("https://api-adresse.data.gouv.fr/search/", params=params)
39 |
40 | return _parse_geojson(r.json())
41 |
42 |
43 | @lru_cache
44 | def get_location_from_coordinates(coordinates: Coordinates) -> Optional[Location]:
45 | params = {"lon": getattr(coordinates, "longitude"), "lat": getattr(coordinates, "latitude")}
46 |
47 | r = requests.get("https://api-adresse.data.gouv.fr/reverse/", params=params)
48 |
49 | return _parse_geojson(r.json())
50 |
51 |
52 | def _parse_geojson(geojson: str) -> Location:
53 | if not geojson["features"]:
54 | return None
55 |
56 | result = geojson["features"][0]
57 | prop = result["properties"]
58 | geometry = result["geometry"]
59 |
60 | if prop["type"] != "housenumber":
61 | logger.warning("GeoJSON imprecise, could not get a house number location.")
62 |
63 | return {
64 | "full_address": prop["label"],
65 | "number_street": prop["name"],
66 | "com_name": prop["city"],
67 | "com_zipcode": prop["postcode"],
68 | "com_insee": prop["citycode"],
69 | "departement": prop["context"].split(",")[0],
70 | "longitude": geometry["coordinates"][0],
71 | "latitude": geometry["coordinates"][1],
72 | }
73 |
--------------------------------------------------------------------------------
/utils/vmd_opendata.py:
--------------------------------------------------------------------------------
1 | def copy_omit_keys(d, omit_keys):
2 | return {k: d[k] for k in set(list(d.keys())) - set(omit_keys)}
3 |
--------------------------------------------------------------------------------