Skip to content

Commit aa54748

Browse files
Add base_url as a parameter to the client
1 parent fe63aec commit aa54748

File tree

4 files changed

+176
-107
lines changed

4 files changed

+176
-107
lines changed

aiocvv/_auth.py

Lines changed: 108 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -42,75 +42,83 @@ async def login(
4242
:param identity: The user's identity, in case multiple are found.
4343
:return: The direct response from the Classeviva API containing the token.
4444
"""
45-
with self.get_cache() as cache:
46-
if "salt" not in cache:
47-
cache["salt"] = bcrypt.gensalt()
48-
49-
# hash the password to cache it
50-
hashed_pw = bcrypt.hashpw(password.encode(), cache["salt"]).decode()
51-
if "logins" not in cache:
52-
cache["logins"] = {}
53-
54-
cache_key = f"{username}:{hashed_pw}"
55-
login_cache = cache["logins"]
56-
if identity:
57-
cache_key += f":{identity}"
58-
59-
# check in the cache for the token and its expiration
60-
if cache_key in login_cache:
61-
this = login_cache[cache_key]
62-
expires_at = datetime.fromisoformat(this["expire"])
63-
if expires_at > datetime.now(timezone.utc):
64-
return this
65-
66-
req = {"uid": username, "pass": password}
67-
if identity:
68-
req["ident"] = identity
69-
70-
# do the actual request to get the token, if expired or not found
71-
async with ClientSession(loop=self.client.loop) as session:
72-
async with session.post(
73-
urljoin(self.client.BASE_URL, "auth/login"),
74-
headers={
75-
"User-Agent": CLIENT_USER_AGENT,
76-
"Z-Dev-Apikey": CLIENT_DEV_APIKEY,
77-
"Content-Type": CLIENT_CONTENT_TP,
78-
},
79-
json=req,
80-
) as resp:
81-
content = await resp.json()
82-
if resp.status == 422:
83-
msg = {
84-
"content": content,
85-
"status": resp.status,
86-
"status_reason": resp.reason,
87-
}
88-
raise find_exc(msg, AuthenticationError)
89-
90-
if "choices" in content and (
91-
not identity
92-
or identity not in [c["ident"] for c in content["choices"]]
93-
):
94-
choices = " * " + "\n * ".join(
95-
f"{c['ident']} ({c['name']})" for c in content["choices"]
96-
)
97-
msg = "Multiple identities have been found, but none has been specified"
98-
if identity:
99-
msg = "Could not find the requested identity"
100-
101-
raise MultiIdentFound(
102-
f"{msg}. Possible choices are:\n{choices}"
103-
)
104-
105-
try:
106-
resp.raise_for_status()
107-
except ClientResponseError as e:
108-
raise AuthenticationError(content) from e
109-
110-
# cache response, will be re-cached as soon as the token expires
111-
login_cache[cache_key] = content
112-
cache["logins"] = login_cache
113-
return content
45+
with self.get_cache() as cache_:
46+
if self.client.base_url not in cache_:
47+
cache_[self.client.base_url] = {}
48+
49+
cache = cache_[self.client.base_url]
50+
51+
try:
52+
if "salt" not in cache:
53+
cache["salt"] = bcrypt.gensalt()
54+
55+
# hash the password to cache it
56+
hashed_pw = bcrypt.hashpw(password.encode(), cache["salt"]).decode()
57+
if "logins" not in cache:
58+
cache["logins"] = {}
59+
60+
cache_key = f"{username}:{hashed_pw}"
61+
login_cache = cache["logins"]
62+
if identity:
63+
cache_key += f":{identity}"
64+
65+
# check in the cache for the token and its expiration
66+
if cache_key in login_cache:
67+
this = login_cache[cache_key]
68+
expires_at = datetime.fromisoformat(this["expire"])
69+
if expires_at > datetime.now(timezone.utc):
70+
return this
71+
72+
req = {"uid": username, "pass": password}
73+
if identity:
74+
req["ident"] = identity
75+
76+
# do the actual request to get the token, if expired or not found
77+
async with ClientSession(loop=self.client.loop) as session:
78+
async with session.post(
79+
urljoin(self.client.base_url, "auth/login"),
80+
headers={
81+
"User-Agent": CLIENT_USER_AGENT,
82+
"Z-Dev-Apikey": CLIENT_DEV_APIKEY,
83+
"Content-Type": CLIENT_CONTENT_TP,
84+
},
85+
json=req,
86+
) as resp:
87+
content = await resp.json()
88+
if resp.status == 422:
89+
msg = {
90+
"content": content,
91+
"status": resp.status,
92+
"status_reason": resp.reason,
93+
}
94+
raise find_exc(msg, AuthenticationError)
95+
96+
if "choices" in content and (
97+
not identity
98+
or identity not in [c["ident"] for c in content["choices"]]
99+
):
100+
choices = " * " + "\n * ".join(
101+
f"{c['ident']} ({c['name']})"
102+
for c in content["choices"]
103+
)
104+
msg = "Multiple identities have been found, but none has been specified"
105+
if identity:
106+
msg = "Could not find the requested identity"
107+
108+
raise MultiIdentFound(
109+
f"{msg}. Possible choices are:\n{choices}"
110+
)
111+
112+
try:
113+
resp.raise_for_status()
114+
except ClientResponseError as e:
115+
raise AuthenticationError(content) from e
116+
117+
# cache response, will be re-cached as soon as the token expires
118+
login_cache[cache_key] = content
119+
return content
120+
finally:
121+
cache_[self.client.base_url] = cache
114122

115123
async def status(self, token: str) -> dict:
116124
"""
@@ -120,28 +128,34 @@ async def status(self, token: str) -> dict:
120128
:param token: The token to check the status of.
121129
:return: The direct response from the Classeviva API.
122130
"""
123-
with self.get_cache() as cache:
124-
if "logins_status" not in cache:
125-
cache["logins_status"] = {}
126-
127-
status = cache["logins_status"]
128-
if token in status:
129-
this = status[token]
130-
expires_at = datetime.fromisoformat(this["expire"])
131-
if expires_at > datetime.now(timezone.utc):
132-
return this
133-
134-
async with ClientSession(loop=self.client.loop) as session:
135-
async with session.get(
136-
urljoin(self.client.BASE_URL, "auth/status"),
137-
headers={
138-
"User-Agent": CLIENT_USER_AGENT,
139-
"Z-Dev-Apikey": CLIENT_DEV_APIKEY,
140-
"Content-Type": CLIENT_CONTENT_TP,
141-
"Z-Auth-Token": token,
142-
},
143-
) as resp:
144-
resp.raise_for_status()
145-
status[token] = (await resp.json())["status"]
146-
cache["logins_status"] = status
147-
return status[token]
131+
with self.get_cache() as cache_:
132+
if self.client.base_url not in cache_:
133+
cache_[self.client.base_url] = {}
134+
135+
cache = cache_[self.client.base_url]
136+
try:
137+
if "logins_status" not in cache:
138+
cache["logins_status"] = {}
139+
140+
status = cache["logins_status"]
141+
if token in status:
142+
this = status[token]
143+
expires_at = datetime.fromisoformat(this["expire"])
144+
if expires_at > datetime.now(timezone.utc):
145+
return this
146+
147+
async with ClientSession(loop=self.client.loop) as session:
148+
async with session.get(
149+
urljoin(self.client.base_url, "auth/status"),
150+
headers={
151+
"User-Agent": CLIENT_USER_AGENT,
152+
"Z-Dev-Apikey": CLIENT_DEV_APIKEY,
153+
"Content-Type": CLIENT_CONTENT_TP,
154+
"Z-Auth-Token": token,
155+
},
156+
) as resp:
157+
resp.raise_for_status()
158+
status[token] = (await resp.json())["status"]
159+
return status[token]
160+
finally:
161+
cache_[self.client.base_url] = cache

aiocvv/client.py

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,23 +71,24 @@ class ClassevivaClient:
7171
:param identity: Optional. The identity for authentication.
7272
:param loop: Optional. The event loop to use.
7373
If not provided, the default event loop will be used.
74+
:param base_url: Optional. The base URL for the Classeviva REST APIs.
75+
Default is `https://web.spaggiari.eu/rest/v1/`.
7476
7577
:type username: str
7678
:type password: str
7779
:type identity: str
7880
:type loop: asyncio.AbstractEventLoop
81+
:type base_url: str
7982
"""
8083

81-
BASE_URL = "https://web.spaggiari.eu/rest/v1/"
82-
PARSED_BASE = urlparse(BASE_URL)
83-
8484
def __init__(
8585
self,
8686
username: str,
8787
password: str,
8888
identity: Optional[str] = None,
8989
*,
9090
loop: Optional[asyncio.AbstractEventLoop] = None,
91+
base_url: str = "https://web.spaggiari.eu/rest/v1/",
9192
):
9293
self.loop = loop or asyncio.get_event_loop()
9394
self.__username = username
@@ -101,6 +102,41 @@ def __init__(
101102
self.__students = None
102103
self.__parents = None
103104
self.__me = None
105+
self._base_url = base_url
106+
self.__parsed_base = urlparse(base_url)
107+
108+
@property
109+
def base_url(self) -> str:
110+
"""
111+
The base URL for the Classeviva REST APIs.
112+
113+
:return: The base URL.
114+
"""
115+
return self._base_url
116+
117+
@base_url.setter
118+
def base_url(self, value: str):
119+
"""
120+
Change the base URL for the Classeviva REST APIs.
121+
This can be useful if:
122+
* You're trying to fetch data from a past school year;
123+
* You're testing against a local or private instance of Classeviva.
124+
125+
.. note::
126+
If you're changing this to get data from a past school year, the
127+
base URL will be something like `https://webYY.spaggiari.eu/rest/v1/`,
128+
where `YY` is the last two digits of the year.
129+
For example, in August 2025, the previous school year was "2024-2025",
130+
so the base URL would be `https://web24.spaggiari.eu/rest/v1/`.
131+
132+
133+
:param value: The new base URL.
134+
:type value: str
135+
136+
:rtype: None
137+
"""
138+
self._base_url = value
139+
self.__parsed_base = urlparse(value)
104140

105141
# Modules #
106142
# Using properties here so only the needed
@@ -248,26 +284,31 @@ async def request(
248284
"""
249285
if urlsplit(endpoint).scheme:
250286
raise ValueError(
251-
f"Invalid URL given: The URL provided is not for {self.BASE_URL}."
287+
f"Invalid URL given: The URL provided is not for {self.base_url}."
252288
)
253289

254-
if not endpoint.startswith(self.BASE_URL):
255-
endpoint = urljoin(self.BASE_URL, endpoint.lstrip("/"))
290+
if not endpoint.startswith(self.base_url):
291+
endpoint = urljoin(self.base_url, endpoint.lstrip("/"))
256292

257293
login = await self.__auth.login(
258294
self.__username, self.__password, self.__identity
259295
)
260296
token = login["token"]
261297

262298
parsed_url = urlparse(endpoint)
263-
cache = await self.loop.run_in_executor(
299+
cache_ = await self.loop.run_in_executor(
264300
None, shelve.open, self._shelf_cache_path
265301
)
302+
if self.base_url not in cache_:
303+
cache_[self.base_url] = {}
304+
305+
cache = cache_[self.base_url]
306+
266307
if "requests" not in cache:
267308
cache["requests"] = {}
268309

269310
reqs_cache = cache["requests"]
270-
part = parsed_url.path[len(self.PARSED_BASE.path) :]
311+
part = parsed_url.path[len(self.__parsed_base.path) :]
271312

272313
try:
273314
_headers = {
@@ -356,8 +397,8 @@ async def request(
356397

357398
return ret
358399
finally:
359-
cache["requests"] = reqs_cache
360-
await self.loop.run_in_executor(None, cache.close)
400+
cache_[self.base_url] = cache
401+
await self.loop.run_in_executor(None, cache_.close)
361402

362403
async def login(self, raise_exceptions: bool = True):
363404
"""

aiocvv/me.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def school(self) -> School:
5959
name=self.__card["schName"],
6060
dedication=self.__card["schDedication"],
6161
city=self.__card["schCity"],
62-
proince=self.__card["schProv"],
62+
province=self.__card["schProv"],
6363
miur_data=MIURData(
6464
self.__card["miurSchoolCode"], self.__card["miurDivisionCode"]
6565
),

example.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,24 @@
99
async def main():
1010
# Log into the account
1111
client = await Client(os.getenv("CVV_USERNAME"), os.getenv("CVV_PASSWORD"))
12+
# Uncomment this to use a specific API instance (e.g. for a specific year like 2024)
13+
#client.base_url = "https://web24.spaggiari.eu/rest/v1/"
14+
await client.login()
1215
begin = date.today() - timedelta(days=7)
1316
end = date.today()
14-
final = [p for p in await client.me.calendar.get_periods() if p.final][-1]
17+
try:
18+
final = [p for p in await client.me.calendar.get_periods() if p.final][-1]
19+
except IndexError:
20+
# Maybe we're going through another school year, so take as granted that it's over
21+
# NOTE: Considering that no data at all is available for the previous school years
22+
# through the API, this could also be a server-side issue and not related to
23+
# the library working or not. If this gets fixed at Classeviva's end, then we
24+
# can remove this workaround.
25+
final = type("Dummy", (object,), {"end": date.today() - timedelta(days=1)})
1526

1627
print(f"Hello, {client.me.name}. ", end="")
1728

29+
print(await client.me.get_grades())
1830
if final.end < date.today():
1931
print("This school year is over! Let's see how you did this year.\n")
2032
total_grades = 0
@@ -72,7 +84,9 @@ async def main():
7284
f"In total, you got {total_grades} grades, "
7385
f"{total_notes} notes and {total_absences} absences."
7486
)
75-
print(f"Your total average grade was {grades_sum / total_grades:.2f}.\n")
87+
print(
88+
f"Your total average grade was {(grades_sum / total_grades if total_grades else 0):.2f}.\n"
89+
)
7690
else:
7791

7892
print("Showing information for the last 7 days.\n")

0 commit comments

Comments
 (0)