ft-tui 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ft_tui/__init__.py +3 -0
- ft_tui/api/__init__.py +8 -0
- ft_tui/api/activity_types.py +120 -0
- ft_tui/api/campus_types.py +22 -0
- ft_tui/api/catalog_types.py +37 -0
- ft_tui/api/client_factory.py +17 -0
- ft_tui/api/client_interface.py +52 -0
- ft_tui/api/clubs_types.py +106 -0
- ft_tui/api/cluster_map_types.py +75 -0
- ft_tui/api/friends_types.py +204 -0
- ft_tui/api/goals_types.py +34 -0
- ft_tui/api/http_client.py +452 -0
- ft_tui/api/keychain.py +82 -0
- ft_tui/api/oauth_client.py +580 -0
- ft_tui/api/registration_types.py +106 -0
- ft_tui/api/slots_types.py +18 -0
- ft_tui/api/social_types.py +71 -0
- ft_tui/api/team_types.py +35 -0
- ft_tui/api/user_profile_types.py +153 -0
- ft_tui/cluster_map/__init__.py +1 -0
- ft_tui/cluster_map/experience.py +38 -0
- ft_tui/cluster_map/geometry.py +60 -0
- ft_tui/cluster_map/layout.json +1 -0
- ft_tui/cluster_map/state.py +93 -0
- ft_tui/config/__init__.py +1 -0
- ft_tui/config/settings.py +27 -0
- ft_tui/db/__init__.py +15 -0
- ft_tui/db/database.py +29 -0
- ft_tui/db/models.py +263 -0
- ft_tui/goto.py +97 -0
- ft_tui/i18n.py +95 -0
- ft_tui/integrations/__init__.py +1 -0
- ft_tui/integrations/zed_lsp.py +173 -0
- ft_tui/locales/es.json +1098 -0
- ft_tui/locales/fr.json +1115 -0
- ft_tui/main.py +72 -0
- ft_tui/services/__init__.py +1 -0
- ft_tui/services/catalog_cache.py +179 -0
- ft_tui/services/curriculum_selection.py +21 -0
- ft_tui/services/cursus.py +41 -0
- ft_tui/services/dashboard_cache.py +77 -0
- ft_tui/services/friends.py +165 -0
- ft_tui/services/fuzzy_search.py +77 -0
- ft_tui/services/git_service.py +92 -0
- ft_tui/services/pdf_checker.py +86 -0
- ft_tui/services/project_activity.py +49 -0
- ft_tui/services/project_catalog.py +21 -0
- ft_tui/services/project_checkout.py +191 -0
- ft_tui/services/project_curriculum.py +51 -0
- ft_tui/services/project_kind.py +15 -0
- ft_tui/services/project_navigation.py +144 -0
- ft_tui/services/project_presentation.py +93 -0
- ft_tui/services/project_registration.py +80 -0
- ft_tui/services/tester_catalog.py +127 -0
- ft_tui/services/tester_docker.py +437 -0
- ft_tui/services/tester_terminal.py +125 -0
- ft_tui/services/time_tracking.py +149 -0
- ft_tui/services/user_profile.py +42 -0
- ft_tui/services/user_settings.py +22 -0
- ft_tui/slots/__init__.py +1 -0
- ft_tui/slots/model.py +99 -0
- ft_tui/testers.toml +36 -0
- ft_tui/ui/__init__.py +1 -0
- ft_tui/ui/account_connection.py +239 -0
- ft_tui/ui/app.py +924 -0
- ft_tui/ui/button.py +238 -0
- ft_tui/ui/club_announcements.py +196 -0
- ft_tui/ui/club_inbox.py +50 -0
- ft_tui/ui/clubs.py +1007 -0
- ft_tui/ui/cluster_issue.py +234 -0
- ft_tui/ui/cluster_map.py +302 -0
- ft_tui/ui/cluster_map_grid.py +376 -0
- ft_tui/ui/cluster_portrait.py +295 -0
- ft_tui/ui/cluster_seat_preview.py +56 -0
- ft_tui/ui/cluster_workstation.py +493 -0
- ft_tui/ui/commands.py +138 -0
- ft_tui/ui/components.py +623 -0
- ft_tui/ui/correction_slots.py +319 -0
- ft_tui/ui/dashboard.py +599 -0
- ft_tui/ui/dev_log.py +263 -0
- ft_tui/ui/friend_project.py +227 -0
- ft_tui/ui/friend_search.py +129 -0
- ft_tui/ui/friend_suggestions.py +231 -0
- ft_tui/ui/friends.py +973 -0
- ft_tui/ui/layout.py +241 -0
- ft_tui/ui/modal.py +37 -0
- ft_tui/ui/navigation.py +27 -0
- ft_tui/ui/onboarding.py +249 -0
- ft_tui/ui/poke_notification.py +157 -0
- ft_tui/ui/profile_sections.py +124 -0
- ft_tui/ui/profile_view.py +171 -0
- ft_tui/ui/project_activity.py +400 -0
- ft_tui/ui/project_browser.py +176 -0
- ft_tui/ui/project_catalog_controller.py +288 -0
- ft_tui/ui/project_details.py +567 -0
- ft_tui/ui/project_navigation.py +173 -0
- ft_tui/ui/project_registration.py +229 -0
- ft_tui/ui/project_settings.py +146 -0
- ft_tui/ui/project_setup.py +183 -0
- ft_tui/ui/project_team.py +381 -0
- ft_tui/ui/project_technical_details.py +36 -0
- ft_tui/ui/project_testers.py +323 -0
- ft_tui/ui/push_events.py +97 -0
- ft_tui/ui/select.py +46 -0
- ft_tui/ui/settings.py +507 -0
- ft_tui/ui/shortcuts.py +77 -0
- ft_tui/ui/slots_calendar.py +233 -0
- ft_tui/ui/social.py +453 -0
- ft_tui/ui/styles/__init__.py +13 -0
- ft_tui/ui/styles/base.py +62 -0
- ft_tui/ui/styles/cluster_map.py +160 -0
- ft_tui/ui/styles/dashboard.py +98 -0
- ft_tui/ui/styles/dev_log.py +20 -0
- ft_tui/ui/styles/friends.py +45 -0
- ft_tui/ui/styles/projects.py +77 -0
- ft_tui/ui/styles/settings.py +35 -0
- ft_tui/ui/styles/user_profile.py +44 -0
- ft_tui/ui/terminal_capabilities.py +96 -0
- ft_tui/ui/user_profile.py +365 -0
- ft_tui/ui/username.py +26 -0
- ft_tui-0.2.2.dist-info/METADATA +496 -0
- ft_tui-0.2.2.dist-info/RECORD +125 -0
- ft_tui-0.2.2.dist-info/WHEEL +5 -0
- ft_tui-0.2.2.dist-info/entry_points.txt +4 -0
- ft_tui-0.2.2.dist-info/top_level.txt +1 -0
ft_tui/__init__.py
ADDED
ft_tui/api/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""42TUI API providers."""
|
|
2
|
+
from .client_interface import ProjectProvider
|
|
3
|
+
from .http_client import HttpProjectProvider
|
|
4
|
+
__all__=['ProjectProvider','HttpProjectProvider']
|
|
5
|
+
|
|
6
|
+
from .keychain import CredentialStore,SecureStoreError
|
|
7
|
+
from .oauth_client import DeviceCredentials,OAuthClient,PairingResult,PairingStart
|
|
8
|
+
__all__ += ['CredentialStore','SecureStoreError','DeviceCredentials','OAuthClient','PairingResult','PairingStart']
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Validate account-owned project activity and discard unrecognized upstream fields."""
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from math import isfinite
|
|
4
|
+
from .catalog_types import positive
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def require(condition):
|
|
8
|
+
if not condition:
|
|
9
|
+
raise ValueError('Invalid project activity response')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def text(value):
|
|
13
|
+
require(value is None or isinstance(value, str) and len(value) <= 1048576)
|
|
14
|
+
return value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def date(value):
|
|
18
|
+
if value is not None:
|
|
19
|
+
require(isinstance(value, str))
|
|
20
|
+
try:
|
|
21
|
+
require(datetime.fromisoformat(value.replace('Z', '+00:00')).tzinfo is not None)
|
|
22
|
+
except (ValueError, OverflowError):
|
|
23
|
+
raise ValueError('Invalid project activity timestamp') from None
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def mark(value):
|
|
28
|
+
require(value is None or type(value) in (int, float) and isfinite(value))
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def source(value):
|
|
33
|
+
require(isinstance(value, dict))
|
|
34
|
+
require(value.get('status') in {'available', 'unavailable', 'unknown'} and type(value.get('stale')) is bool)
|
|
35
|
+
at = date(value.get('collected_at'))
|
|
36
|
+
require(value['status'] != 'available' or at is not None)
|
|
37
|
+
reason = value.get('reason_code')
|
|
38
|
+
require(reason in (None, 'permission_denied'))
|
|
39
|
+
return {'status': value['status'], 'stale': value['stale'], 'collected_at': at,
|
|
40
|
+
**({'reason_code': reason} if reason else {})}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def rows(value):
|
|
44
|
+
require(isinstance(value, list) and len(value) <= 10000)
|
|
45
|
+
require(all(isinstance(x, dict) and positive(x.get('id')) for x in value))
|
|
46
|
+
require(len({x['id'] for x in value}) == len(value))
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def identity(value, project_id, scope, account_id):
|
|
51
|
+
require(positive(project_id) and isinstance(value, dict))
|
|
52
|
+
require(value.get('account_id') == account_id and value.get('project_id') == project_id)
|
|
53
|
+
require(value.get('context') == scope.params())
|
|
54
|
+
return {'account_id': account_id, 'project_id': project_id, 'context': scope.params()}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def project_activity(value, project_id, scope, account_id, *, subject_user_id=None):
|
|
58
|
+
result = identity(value, project_id, scope, account_id)
|
|
59
|
+
require(value.get('current_team_id') is None or positive(value['current_team_id']))
|
|
60
|
+
teams = []
|
|
61
|
+
for team in rows(value.get('teams')):
|
|
62
|
+
require(team.get('project_id') == project_id)
|
|
63
|
+
require(isinstance(team.get('name'), str) and isinstance(team.get('status'), str))
|
|
64
|
+
require(team.get('validated') is None or type(team['validated']) is bool)
|
|
65
|
+
members = []
|
|
66
|
+
for member in rows(team.get('members')):
|
|
67
|
+
require(isinstance(member.get('login'), str) and type(member.get('leader')) is bool)
|
|
68
|
+
members.append({k: member[k] for k in ('id', 'login', 'leader')})
|
|
69
|
+
require(any(str(m['id']) == (subject_user_id or account_id) for m in members))
|
|
70
|
+
teams.append({'id': team['id'], 'project_id': project_id, 'name': team['name'], 'status': team['status'],
|
|
71
|
+
'validated': team.get('validated'), 'final_mark': mark(team.get('final_mark')),
|
|
72
|
+
'repo_url': text(team.get('repo_url')), 'members': members,
|
|
73
|
+
**{k: date(team.get(k)) for k in ('created_at', 'locked_at', 'closed_at')}})
|
|
74
|
+
return {**result, 'current_team_id': value.get('current_team_id'), 'teams': teams, 'source': source(value.get('source'))}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def team_evaluations(value, project_id, team_id, scope, account_id):
|
|
78
|
+
result = identity(value, project_id, scope, account_id)
|
|
79
|
+
require(positive(team_id) and value.get('team_id') == team_id)
|
|
80
|
+
groups = {}
|
|
81
|
+
for name in ('peer', 'automated'):
|
|
82
|
+
group = value.get(name)
|
|
83
|
+
require(isinstance(group, dict))
|
|
84
|
+
items = []
|
|
85
|
+
for item in rows(group.get('items')):
|
|
86
|
+
clean = {'id': item['id'], 'final_mark': mark(item.get('final_mark')), 'comment': text(item.get('comment'))}
|
|
87
|
+
if name == 'automated':
|
|
88
|
+
require(isinstance(item.get('name'), str))
|
|
89
|
+
clean.update(name=item['name'], created_at=date(item.get('created_at')))
|
|
90
|
+
else:
|
|
91
|
+
require(item.get('team_id') is None or item['team_id'] == team_id)
|
|
92
|
+
require(type(item.get('details_unavailable', False)) is bool)
|
|
93
|
+
corrector = item.get('corrector')
|
|
94
|
+
require(corrector is None or isinstance(corrector, dict) and positive(corrector.get('id')) and isinstance(corrector.get('login'), str))
|
|
95
|
+
require(type(item.get('participants_hidden')) is bool)
|
|
96
|
+
flag = item.get('flag')
|
|
97
|
+
require(flag is None or isinstance(flag, dict) and isinstance(flag.get('name'), str) and (flag.get('positive') is None or type(flag['positive']) is bool))
|
|
98
|
+
feedbacks = item.get('feedbacks')
|
|
99
|
+
require(isinstance(feedbacks, list) and len(feedbacks) <= 1000)
|
|
100
|
+
require(all(isinstance(f, dict) for f in feedbacks))
|
|
101
|
+
clean.update(details_unavailable=item.get('details_unavailable', False), team_id=item.get('team_id'), corrector={k: corrector[k] for k in ('id', 'login')} if corrector else None,
|
|
102
|
+
participants_hidden=item['participants_hidden'], begin_at=date(item.get('begin_at')),
|
|
103
|
+
filled_at=date(item.get('filled_at')), feedback=text(item.get('feedback')),
|
|
104
|
+
feedback_rating=mark(item.get('feedback_rating')), flag=flag,
|
|
105
|
+
feedbacks=[{'comment': text(f.get('comment')), 'rating': mark(f.get('rating'))} for f in feedbacks])
|
|
106
|
+
items.append(clean)
|
|
107
|
+
groups[name] = {'items': items, 'source': source(group.get('source'))}
|
|
108
|
+
return {**result, 'team_id': team_id, **groups}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def friend_activity(value, project_id, attempt_id, user_id, scope, account_id):
|
|
112
|
+
require(isinstance(value,dict) and value.get('subject_user_id')==user_id and value.get('attempt_id')==attempt_id)
|
|
113
|
+
clean=project_activity(value,project_id,scope,account_id,subject_user_id=user_id)
|
|
114
|
+
require(clean['current_team_id'] is None or any(team['id']==clean['current_team_id'] for team in clean['teams']))
|
|
115
|
+
return {**clean,'subject_user_id':user_id,'attempt_id':attempt_id}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def friend_evaluations(value, project_id, attempt_id, user_id, team_id, scope, account_id):
|
|
119
|
+
require(isinstance(value,dict) and value.get('subject_user_id')==user_id and value.get('attempt_id')==attempt_id)
|
|
120
|
+
return {**team_evaluations(value,project_id,team_id,scope,account_id),'subject_user_id':user_id,'attempt_id':attempt_id}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Validate the shared public campus directory before caching it locally."""
|
|
2
|
+
from .catalog_types import positive
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def campus_directory(value):
|
|
6
|
+
rows = value.get('campuses') if isinstance(value, dict) else None
|
|
7
|
+
if not isinstance(rows, list) or len(rows) > 10000:
|
|
8
|
+
raise ValueError('Invalid campus directory')
|
|
9
|
+
result, ids = [], set()
|
|
10
|
+
for row in rows:
|
|
11
|
+
if not isinstance(row, dict) or not positive(row.get('id')) or row['id'] in ids:
|
|
12
|
+
raise ValueError('Invalid campus directory')
|
|
13
|
+
if not isinstance(row.get('name'), str) or not row['name'].strip() or len(row['name']) > 512:
|
|
14
|
+
raise ValueError('Invalid campus directory')
|
|
15
|
+
for field in ('country', 'city'):
|
|
16
|
+
if row.get(field) is not None and (not isinstance(row[field], str) or len(row[field]) > 512):
|
|
17
|
+
raise ValueError('Invalid campus directory')
|
|
18
|
+
if row.get('active') is not None and type(row['active']) is not bool:
|
|
19
|
+
raise ValueError('Invalid campus directory')
|
|
20
|
+
ids.add(row['id'])
|
|
21
|
+
result.append({key: row.get(key) for key in ('id', 'name', 'country', 'city', 'active')})
|
|
22
|
+
return result
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Public catalog DTO validation, independent from local ORM identities."""
|
|
2
|
+
from dataclasses import dataclass,asdict
|
|
3
|
+
import math
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class CatalogScope:
|
|
6
|
+
cursus_id:int
|
|
7
|
+
campus_id:int
|
|
8
|
+
def params(self):return asdict(self)
|
|
9
|
+
|
|
10
|
+
def positive(value):return type(value) is int and value>0
|
|
11
|
+
|
|
12
|
+
def project_summary(value):
|
|
13
|
+
if not isinstance(value,dict) or not positive(value.get('project_id')):
|
|
14
|
+
raise ValueError('Invalid official project ID')
|
|
15
|
+
for field in ('slug','name','kind'):
|
|
16
|
+
if not isinstance(value.get(field),str) or not value[field]:raise ValueError('Invalid catalog project')
|
|
17
|
+
for field in ('xp','rank'):
|
|
18
|
+
n=value.get(field)
|
|
19
|
+
if n is not None and (type(n) is not int or n<0):raise ValueError('Invalid catalog number')
|
|
20
|
+
if value.get('track') not in (None,'old','new','shared') or value.get('section') not in ('common-core','specialization','other'):
|
|
21
|
+
raise ValueError('Invalid catalog classification')
|
|
22
|
+
if value.get('duration') is not None and not isinstance(value['duration'],str):raise ValueError('Invalid duration')
|
|
23
|
+
if value.get('project_mode','unknown') not in {'solo','group','unknown'}:raise ValueError('Invalid project mode')
|
|
24
|
+
if value.get('team_size') is not None and not positive(value['team_size']):raise ValueError('Invalid team size')
|
|
25
|
+
fields=('team_size','project_mode','project_id','slug','name','kind','xp','duration','rank','track','section','section_source',
|
|
26
|
+
'evidence_status','snapshot_id','description','rules','clauses','visual_links','branches',
|
|
27
|
+
'web_node_id','coordinates')
|
|
28
|
+
return {field:value[field] for field in fields if field in value}
|
|
29
|
+
|
|
30
|
+
def catalog_page(value,scope):
|
|
31
|
+
if not isinstance(value,dict) or not isinstance(value.get('items'),list) or len(value['items'])>100 or not isinstance(value.get('snapshot_id'),str):raise ValueError('Invalid catalog page')
|
|
32
|
+
meta=value.get('meta',{})
|
|
33
|
+
if meta.get('cursus_id')!=scope.cursus_id or meta.get('campus_id')!=scope.campus_id:raise ValueError('Catalog scope mismatch')
|
|
34
|
+
if value.get('next_cursor') is not None and not isinstance(value['next_cursor'],str):raise ValueError('Invalid catalog cursor')
|
|
35
|
+
clean={**value,'items':[project_summary(x) for x in value['items']]}
|
|
36
|
+
if len({x['project_id'] for x in clean['items']})!=len(clean['items']):raise ValueError('Duplicate catalog project')
|
|
37
|
+
return clean
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Select the online provider from secure device-session state."""
|
|
2
|
+
|
|
3
|
+
from .http_client import HttpProjectProvider
|
|
4
|
+
from .oauth_client import OAuthClient
|
|
5
|
+
from .client_interface import ProjectProvider
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def provider_for_device(oauth_client: OAuthClient) -> ProjectProvider | None:
|
|
9
|
+
"""Return no provider until this device has a usable 42TUI session."""
|
|
10
|
+
try:
|
|
11
|
+
credentials = oauth_client.credentials
|
|
12
|
+
except Exception:
|
|
13
|
+
# Missing/unavailable OS keychain support must not prevent local startup.
|
|
14
|
+
return None
|
|
15
|
+
if credentials is not None and credentials.access_token and credentials.refresh_token:
|
|
16
|
+
return HttpProjectProvider(oauth_client=oauth_client)
|
|
17
|
+
return None
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Provider contract for the centralized 42TUI API."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class CursusSnapshot:
|
|
11
|
+
"""Cursus data supplied by the centralized API."""
|
|
12
|
+
|
|
13
|
+
id: int
|
|
14
|
+
name: str
|
|
15
|
+
begin_at: datetime | None = None
|
|
16
|
+
end_at: datetime | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProjectProvider(Protocol):
|
|
20
|
+
"""Authenticated catalog reader. No browser credentials enter this interface."""
|
|
21
|
+
def get_social(self) -> dict: ...
|
|
22
|
+
def get_poke_neighbours(self) -> list[dict]: ...
|
|
23
|
+
def social_action(self, draft: dict) -> dict: ...
|
|
24
|
+
def event_stream(self, stop): ...
|
|
25
|
+
def list_cursuses(self) -> list[CursusSnapshot]: ...
|
|
26
|
+
def list_campuses(self) -> list[dict]: ...
|
|
27
|
+
def get_profile(self) -> dict: ...
|
|
28
|
+
def get_cluster_map(self) -> dict: ...
|
|
29
|
+
def get_cluster_portrait(self, hostname: str) -> dict: ...
|
|
30
|
+
def get_cluster_issues(self, hostname: str, *, include_resolved: bool = False, cursor: str | None = None) -> dict: ...
|
|
31
|
+
def get_cluster_issue_options(self) -> dict: ...
|
|
32
|
+
def submit_cluster_issue(self, hostname: str, draft: dict, idempotency_key: str) -> dict: ...
|
|
33
|
+
def search_students(self, query: str, *, limit=20, exclude_ids=()) -> dict: ...
|
|
34
|
+
def list_friend_directory_page(self, campus_id: int, page: int = 1) -> dict: ...
|
|
35
|
+
def get_friend_core(self, login: str, cursus_id: int, *, refresh=False, user_id=None) -> dict: ...
|
|
36
|
+
def get_friend(self, login: str, cursus_id: int, *, refresh=False, user_id=None) -> dict: ...
|
|
37
|
+
def get_goals(self, cursus_id: int, account_id: str) -> dict: ...
|
|
38
|
+
def list_catalog_page(self, scope, **filters) -> dict: ...
|
|
39
|
+
def get_catalog_project(self, project_id: int, scope, snapshot_id=None) -> dict: ...
|
|
40
|
+
def list_branches(self, scope, track: str, cursor=None) -> dict: ...
|
|
41
|
+
def get_branch(self, scope, track: str, branch_id: str, snapshot_id=None) -> dict: ...
|
|
42
|
+
def get_account_projects(self, cursus_id: int, cursor=None) -> dict: ...
|
|
43
|
+
def get_project_activity(self, project_id: int, scope, account_id: str, *, refresh=False) -> dict: ...
|
|
44
|
+
def get_team_evaluations(self, project_id: int, team_id: int, scope, account_id: str, *, refresh=False) -> dict: ...
|
|
45
|
+
def get_project_team(self, project_id: int, scope, account_id: str) -> dict: ...
|
|
46
|
+
def change_project_team(self, project_id: int, scope, account_id: str, action: dict, idempotency_key: str) -> dict: ...
|
|
47
|
+
def get_project_registration(self, project_id: int, scope, account_id: str) -> dict: ...
|
|
48
|
+
def refresh_project_registration(self, project_id: int, scope, account_id: str) -> dict: ...
|
|
49
|
+
def register_project(self, project_id: int, scope, account_id: str, idempotency_key: str) -> dict: ...
|
|
50
|
+
def get_curriculum(self, scope) -> dict: ...
|
|
51
|
+
def get_subject_reference_sha256(self, slug: str) -> str | None: ...
|
|
52
|
+
def download_subject_pdf(self, slug: str, destination: Path) -> bool: ...
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Validation for the shared Clubs contract before rendering server data."""
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def timestamp(value):
|
|
7
|
+
if not isinstance(value, str):
|
|
8
|
+
raise ValueError('Invalid club timestamp')
|
|
9
|
+
parsed = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
10
|
+
if parsed.tzinfo is None:
|
|
11
|
+
raise ValueError('Missing club timezone')
|
|
12
|
+
return value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def club_id(value):
|
|
16
|
+
if not isinstance(value, str):
|
|
17
|
+
raise ValueError('Invalid club ID')
|
|
18
|
+
if value.startswith('campus-') and value[7:].isdigit() and int(value[7:]) > 0:
|
|
19
|
+
return value
|
|
20
|
+
UUID(value)
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def preferences(value):
|
|
25
|
+
if not isinstance(value, dict) or type(value.get('dnd')) is not bool or not isinstance(value.get('bells'), dict):
|
|
26
|
+
raise ValueError('Invalid club preferences')
|
|
27
|
+
for key, bell in value['bells'].items():
|
|
28
|
+
club_id(key)
|
|
29
|
+
if type(bell) is not bool:
|
|
30
|
+
raise ValueError('Invalid club bell')
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def club(value):
|
|
35
|
+
if not isinstance(value, dict):
|
|
36
|
+
raise ValueError('Invalid club')
|
|
37
|
+
club_id(value.get('id'))
|
|
38
|
+
if (type(value.get('campus_id')) is not int or value['campus_id'] < 1
|
|
39
|
+
or any(not isinstance(value.get(k), str) for k in ('name', 'description'))
|
|
40
|
+
or any(type(value.get(k)) is not bool for k in ('system', 'archived'))
|
|
41
|
+
or 'can_announce' in value and type(value['can_announce']) is not bool
|
|
42
|
+
or value.get('my_role') not in ('creator', 'delegate', 'member', None)
|
|
43
|
+
or not isinstance(value.get('members'), list) or len(value['members']) > 100
|
|
44
|
+
or not isinstance(value.get('activities'), list) or len(value['activities']) > 100
|
|
45
|
+
or not isinstance(value.get('events'), list) or len(value['events']) > 100):
|
|
46
|
+
raise ValueError('Invalid club data')
|
|
47
|
+
for member in value['members']:
|
|
48
|
+
if (not isinstance(member, dict) or not isinstance(member.get('account_id'), str)
|
|
49
|
+
or not member['account_id'].isdigit() or not isinstance(member.get('login'), str)
|
|
50
|
+
or member.get('role') not in ('creator', 'delegate', 'member')):
|
|
51
|
+
raise ValueError('Invalid club member')
|
|
52
|
+
for item in value['activities']:
|
|
53
|
+
UUID(item['id'])
|
|
54
|
+
timestamp(item.get('created_at'))
|
|
55
|
+
timestamp(item.get('expires_at'))
|
|
56
|
+
if (item.get('status') not in ('recruiting', 'ready', 'started', 'finished', 'cancelled', 'expired')
|
|
57
|
+
or any(not isinstance(item.get(k), str) for k in ('title', 'location', 'organizer_id', 'created_at', 'expires_at'))
|
|
58
|
+
or type(item.get('minimum')) is not int or type(item.get('capacity')) is not int
|
|
59
|
+
or not 1 <= item['minimum'] <= item['capacity'] <= 100
|
|
60
|
+
or any(not isinstance(item.get(k), list) or len(item[k]) > 100
|
|
61
|
+
or any(not isinstance(i, str) or not i.isdigit() for i in item[k])
|
|
62
|
+
for k in ('participants', 'waitlist'))
|
|
63
|
+
or len(item['participants']) > item['capacity']):
|
|
64
|
+
raise ValueError('Invalid club activity')
|
|
65
|
+
for item in value['events']:
|
|
66
|
+
event(item)
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def event(value):
|
|
71
|
+
if (not isinstance(value, dict) or type(value.get('sequence')) is not int
|
|
72
|
+
or value.get('kind') not in ('activity', 'ready', 'started', 'cancelled', 'announcement')
|
|
73
|
+
or any(not isinstance(value.get(k), str) for k in ('actor_id', 'title', 'message', 'created_at', 'expires_at'))):
|
|
74
|
+
raise ValueError('Invalid club event')
|
|
75
|
+
for key, limit in (('actor_login', 100), ('location', 200)):
|
|
76
|
+
if key in value and (not isinstance(value[key], str) or not 1 <= len(value[key]) <= limit):
|
|
77
|
+
raise ValueError('Invalid announcement metadata')
|
|
78
|
+
if 'event_at' in value:
|
|
79
|
+
timestamp(value['event_at'])
|
|
80
|
+
timestamp(value.get('created_at'))
|
|
81
|
+
timestamp(value.get('expires_at'))
|
|
82
|
+
if value.get('activity_id') is not None:
|
|
83
|
+
UUID(value['activity_id'])
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def overview(value):
|
|
88
|
+
preferences(value)
|
|
89
|
+
if (not isinstance(value.get('account_id'), str) or not value['account_id'].isdigit()
|
|
90
|
+
or not isinstance(value.get('clubs'), list)):
|
|
91
|
+
raise ValueError('Invalid clubs overview')
|
|
92
|
+
for item in value['clubs']:
|
|
93
|
+
club(item)
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def poll(value):
|
|
98
|
+
preferences(value)
|
|
99
|
+
if not isinstance(value.get('notifications'), list) or len(value['notifications']) > 5:
|
|
100
|
+
raise ValueError('Invalid club notifications')
|
|
101
|
+
for item in value['notifications']:
|
|
102
|
+
club_id(item['club_id'])
|
|
103
|
+
if not isinstance(item.get('club_name'), str):
|
|
104
|
+
raise ValueError('Invalid club notification')
|
|
105
|
+
event(item['event'])
|
|
106
|
+
return value
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Validate the authenticated cluster snapshot before UI consumption."""
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
import re
|
|
4
|
+
from ft_tui.cluster_map.geometry import positions
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _meta(value):
|
|
8
|
+
if not isinstance(value,dict) or type(value.get('stale')) is not bool:
|
|
9
|
+
raise ValueError('Invalid cluster freshness')
|
|
10
|
+
stamp = datetime.fromisoformat(value['cached_at'].replace('Z','+00:00'))
|
|
11
|
+
if stamp.tzinfo is None:
|
|
12
|
+
raise ValueError('Invalid cluster freshness')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cluster_portrait(value: object, hostname: str) -> dict:
|
|
16
|
+
if not isinstance(value,dict) or value.get('hostname')!=hostname or value.get('status') not in ('ready','pending','unavailable'):
|
|
17
|
+
raise ValueError('Invalid cluster portrait')
|
|
18
|
+
source=value.get('image_url');image=value.get('image_base64')
|
|
19
|
+
if source is not None and (not isinstance(source,str) or len(source)>2048):raise ValueError('Invalid cluster portrait')
|
|
20
|
+
if value['status']=='ready':
|
|
21
|
+
if not source or not isinstance(image,str) or not 0<len(image)<=699052:raise ValueError('Invalid cluster portrait')
|
|
22
|
+
elif image is not None:raise ValueError('Invalid cluster portrait')
|
|
23
|
+
_meta(value.get('meta'))
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cluster_map_snapshot(value: object) -> dict:
|
|
28
|
+
def fail(): raise ValueError('Invalid cluster snapshot')
|
|
29
|
+
if not isinstance(value,dict) or type(value.get('campus_id')) is not int or not isinstance(value.get('machines'),list) or len(value['machines'])>2000:
|
|
30
|
+
fail()
|
|
31
|
+
seen=set()
|
|
32
|
+
for row in value['machines']:
|
|
33
|
+
if not isinstance(row,dict):fail()
|
|
34
|
+
host=row.get('hostname')
|
|
35
|
+
if not isinstance(host,str) or not re.fullmatch(r'[a-zA-Z0-9.-]{1,253}\.42angouleme\.fr',host) or host.lower() in seen:fail()
|
|
36
|
+
seen.add(host.lower())
|
|
37
|
+
if not isinstance(row.get('status'),str) or row.get('is_locked') is not None and type(row['is_locked']) is not bool:fail()
|
|
38
|
+
for key in ('login','exam_user','lock_time'):
|
|
39
|
+
if row.get(key) is not None and (not isinstance(row[key],str) or len(row[key])>128 or any(ord(c)<32 for c in row[key])):fail()
|
|
40
|
+
person=row.get('person')
|
|
41
|
+
if person is not None and (not isinstance(person,dict) or person.get('login')!=row.get('login')):fail()
|
|
42
|
+
if person:
|
|
43
|
+
for key,limit in (('display_name',513),('image_url',2048),('user_id',16)):
|
|
44
|
+
if person.get(key) is not None and (not isinstance(person[key],str) or len(person[key])>limit):fail()
|
|
45
|
+
issues=row.get('issues')
|
|
46
|
+
if issues is not None:
|
|
47
|
+
if not isinstance(issues,dict) or issues.get('availability') not in ('available','unavailable'):fail()
|
|
48
|
+
count=issues.get('open_count')
|
|
49
|
+
if issues['availability']=='available' and (type(count) is not int or not 0<=count<=10000):fail()
|
|
50
|
+
if issues['availability']=='unavailable' and count is not None:fail()
|
|
51
|
+
if row.get('storage') is not None and (not isinstance(row['storage'],dict) or any(v is not None and not isinstance(v,str) for v in row['storage'].values())):fail()
|
|
52
|
+
try:_meta(value.get('meta'))
|
|
53
|
+
except (KeyError,TypeError,AttributeError,ValueError):fail()
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cluster_issue_page(value: object, hostname: str, include_resolved: bool) -> dict:
|
|
58
|
+
def fail():raise ValueError('Invalid cluster reports')
|
|
59
|
+
if not isinstance(value,dict) or value.get('hostname')!=hostname or value.get('include_resolved') is not include_resolved:fail()
|
|
60
|
+
if not isinstance(value.get('items'),list) or len(value['items'])>50:fail()
|
|
61
|
+
cursor=value.get('next_cursor')
|
|
62
|
+
if cursor is not None and (not isinstance(cursor,str) or not re.fullmatch(r'[1-9]\d{0,2}',cursor) or int(cursor)>200):fail()
|
|
63
|
+
seen=set()
|
|
64
|
+
for item in value['items']:
|
|
65
|
+
if not isinstance(item,dict) or not isinstance(item.get('id'),str) or item['id'] in seen or item.get('hostname')!=hostname:fail()
|
|
66
|
+
seen.add(item['id'])
|
|
67
|
+
if item.get('status') not in (('open','in_progress','resolved') if include_resolved else ('open','in_progress')):fail()
|
|
68
|
+
for field in ('title','description'):
|
|
69
|
+
if not isinstance(item.get(field),str) or len(item[field])>20000:fail()
|
|
70
|
+
if not isinstance(item.get('history'),list) or len(item['history'])>200:fail()
|
|
71
|
+
for entry in item['history']:
|
|
72
|
+
if not isinstance(entry,dict) or not isinstance(entry.get('content'),str) or len(entry['content'])>20000:fail()
|
|
73
|
+
try:_meta(value.get('meta'))
|
|
74
|
+
except (KeyError,TypeError,AttributeError,ValueError):fail()
|
|
75
|
+
return value
|