skypro-cli-tool 0.1.0__tar.gz

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.
Files changed (39) hide show
  1. skypro_cli_tool-0.1.0/PKG-INFO +13 -0
  2. skypro_cli_tool-0.1.0/README.md +1 -0
  3. skypro_cli_tool-0.1.0/pyproject.toml +97 -0
  4. skypro_cli_tool-0.1.0/src/skypro/__init__.py +0 -0
  5. skypro_cli_tool-0.1.0/src/skypro/cli/__init__.py +0 -0
  6. skypro_cli_tool-0.1.0/src/skypro/cli/app.py +25 -0
  7. skypro_cli_tool-0.1.0/src/skypro/cli/commands/__init__.py +3 -0
  8. skypro_cli_tool-0.1.0/src/skypro/cli/commands/summary.py +38 -0
  9. skypro_cli_tool-0.1.0/src/skypro/cli/renders/__init__.py +3 -0
  10. skypro_cli_tool-0.1.0/src/skypro/cli/renders/summary_table.py +44 -0
  11. skypro_cli_tool-0.1.0/src/skypro/config/__init__.py +0 -0
  12. skypro_cli_tool-0.1.0/src/skypro/config/logging.py +51 -0
  13. skypro_cli_tool-0.1.0/src/skypro/config/settings.py +29 -0
  14. skypro_cli_tool-0.1.0/src/skypro/domain/__init__.py +0 -0
  15. skypro_cli_tool-0.1.0/src/skypro/domain/enums.py +9 -0
  16. skypro_cli_tool-0.1.0/src/skypro/domain/errors.py +10 -0
  17. skypro_cli_tool-0.1.0/src/skypro/domain/models.py +33 -0
  18. skypro_cli_tool-0.1.0/src/skypro/domain/services.py +37 -0
  19. skypro_cli_tool-0.1.0/src/skypro/infra/__init__.py +0 -0
  20. skypro_cli_tool-0.1.0/src/skypro/infra/api/__init__.py +0 -0
  21. skypro_cli_tool-0.1.0/src/skypro/infra/api/base.py +31 -0
  22. skypro_cli_tool-0.1.0/src/skypro/infra/api/errors.py +4 -0
  23. skypro_cli_tool-0.1.0/src/skypro/infra/api/skypro/__init__.py +0 -0
  24. skypro_cli_tool-0.1.0/src/skypro/infra/api/skypro/client.py +78 -0
  25. skypro_cli_tool-0.1.0/src/skypro/infra/api/skypro/dto.py +13 -0
  26. skypro_cli_tool-0.1.0/src/skypro/infra/http/__init__.py +0 -0
  27. skypro_cli_tool-0.1.0/src/skypro/infra/http/client.py +64 -0
  28. skypro_cli_tool-0.1.0/src/skypro/infra/http/errors.py +1 -0
  29. skypro_cli_tool-0.1.0/src/skypro/infra/repositories/__init__.py +4 -0
  30. skypro_cli_tool-0.1.0/src/skypro/infra/repositories/errors.py +7 -0
  31. skypro_cli_tool-0.1.0/src/skypro/infra/repositories/summary.py +30 -0
  32. skypro_cli_tool-0.1.0/src/skypro/infra/repositories/work.py +27 -0
  33. skypro_cli_tool-0.1.0/src/skypro/logs/skypro.log +11 -0
  34. skypro_cli_tool-0.1.0/src/skypro/logs/skypro.log.1 +100 -0
  35. skypro_cli_tool-0.1.0/src/skypro/main.py +14 -0
  36. skypro_cli_tool-0.1.0/src/skypro/use_cases/__init__.py +3 -0
  37. skypro_cli_tool-0.1.0/src/skypro/use_cases/get_summary_info.py +28 -0
  38. skypro_cli_tool-0.1.0/src/skypro/utils.py +14 -0
  39. skypro_cli_tool-0.1.0/src/skypro/work_prices.json +7 -0
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.3
2
+ Name: skypro-cli-tool
3
+ Version: 0.1.0
4
+ Summary: SkyPro cli tool
5
+ Requires-Dist: aiohttp>=3.13.5
6
+ Requires-Dist: httpx>=0.28.1
7
+ Requires-Dist: pydantic>=2.13.3
8
+ Requires-Dist: pydantic-settings>=2.14.0
9
+ Requires-Dist: typer>=0.25.0
10
+ Requires-Python: >=3.13
11
+ Description-Content-Type: text/markdown
12
+
13
+ # SkyPro cli tool
@@ -0,0 +1 @@
1
+ # SkyPro cli tool
@@ -0,0 +1,97 @@
1
+ [project]
2
+ name = "skypro-cli-tool"
3
+ version = "0.1.0"
4
+ description = "SkyPro cli tool"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "aiohttp>=3.13.5",
9
+ "httpx>=0.28.1",
10
+ "pydantic>=2.13.3",
11
+ "pydantic-settings>=2.14.0",
12
+ "typer>=0.25.0",
13
+ ]
14
+
15
+ [dependency-groups]
16
+ test = [
17
+ "freezegun>=1.5.5",
18
+ "pytest>=9.0.3",
19
+ "pytest-asyncio>=1.3.0",
20
+ "pytest-cov>=7.1.0",
21
+ "pytest-httpx>=0.36.2",
22
+ "pytest-mock>=3.15.1",
23
+ ]
24
+ lint = [
25
+ "ruff>=0.15.12",
26
+ ]
27
+
28
+ [project.scripts]
29
+ sky-cli = "skypro.main:main"
30
+
31
+ [tool.ruff]
32
+ target-version = "py313"
33
+ line-length = 88
34
+ respect-gitignore = true
35
+
36
+ [tool.ruff.format]
37
+ quote-style = "single"
38
+ indent-style = "space"
39
+ line-ending = "lf"
40
+ skip-magic-trailing-comma = false
41
+
42
+ [tool.ruff.lint]
43
+ ignore = [
44
+ "ANN401", # Dynamically typed expressions (typing.Any) are disallowed
45
+ "CPY001", # Missing copyright notice at top of file
46
+ "COM812", # Trailing comma missing
47
+ "D", # Rules about docstrings
48
+ "DOC", # Rules about docstrings
49
+ "EM101", # Exception must not use a string literal, assign to variable first
50
+ "EM102", # Exception must not use an f-string literal, assign to variable first
51
+ "TRY003", # Avoid specifying long messages outside the exception class
52
+ "RUF001", # String contains ambiguous symbol
53
+ ]
54
+ select = ["ALL"]
55
+ preview = true
56
+
57
+ [tool.ruff.lint.per-file-ignores]
58
+ "tests/*" = [
59
+ "ANN", # Missing type annotation
60
+ "S101", # Use of `assert` detected
61
+ "PLR6301", # Method could be a function, class method, or static method
62
+ "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes
63
+ "PLR2004", # Magic value used in comparison
64
+ ]
65
+
66
+ [tool.ruff.lint.isort]
67
+ combine-as-imports = true
68
+
69
+ [tool.ruff.lint.flake8-quotes]
70
+ inline-quotes = "single"
71
+
72
+ [tool.pytest]
73
+ asyncio_mode = "auto"
74
+ asyncio_debug = true
75
+ addopts = ["--cov=src", "--cov-report", "term-missing"]
76
+
77
+ [tool.coverage.run]
78
+ branch = true
79
+ omit = [
80
+ "tests/*",
81
+ ".venv/*",
82
+ ".github",
83
+ "*/main.py",
84
+ "*/config/logging.py",
85
+ ]
86
+
87
+ [tool.coverage.report]
88
+ show_missing = true
89
+ skip_covered = true
90
+
91
+ [build-system]
92
+ requires = ["uv_build>=0.8.14,<0.9.0"]
93
+ build-backend = "uv_build"
94
+
95
+ [tool.uv.build-backend]
96
+ module-name = "skypro"
97
+ module-root = "src"
File without changes
File without changes
@@ -0,0 +1,25 @@
1
+ import asyncio
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from typer import Option
6
+
7
+ from skypro.config.logging import enable_console_logging
8
+
9
+ from . import commands
10
+
11
+ app = typer.Typer()
12
+ console = Console()
13
+
14
+
15
+ @app.command(name='summary')
16
+ def get_summary(
17
+ year: int | None = Option(None, help='Год для расчета'),
18
+ month: int | None = Option(None, help='Месяц для расчета'),
19
+ *,
20
+ verbose: bool = typer.Option(False, '-v', '--verbose'), # noqa: FBT003
21
+ ) -> None:
22
+ """Расчет стоимости работ"""
23
+ if verbose:
24
+ enable_console_logging(console)
25
+ asyncio.run(commands.show_summary(console, year, month))
@@ -0,0 +1,3 @@
1
+ from .summary import show_summary
2
+
3
+ __all__ = ('show_summary',)
@@ -0,0 +1,38 @@
1
+ from calendar import IllegalMonthError
2
+ from datetime import UTC, date, datetime
3
+
4
+ from click.exceptions import Exit
5
+ from rich.console import Console
6
+
7
+ from skypro.cli.renders import render_summary_table
8
+ from skypro.domain.errors import DomainError
9
+ from skypro.infra.repositories.errors import RepositoryError
10
+ from skypro.use_cases import get_summary_info
11
+ from skypro.utils import get_month_range
12
+
13
+
14
+ async def show_summary(
15
+ console: Console,
16
+ year: int | None,
17
+ month: int | None,
18
+ ) -> None:
19
+ try:
20
+ start, end = resolve_period(year, month)
21
+
22
+ with console.status('[cyan]Расчет стоимости работ'):
23
+ report, total, after_tax = await get_summary_info(start, end)
24
+
25
+ table = render_summary_table(report, total, after_tax)
26
+ console.print(table)
27
+ except (RepositoryError, DomainError, IllegalMonthError) as e:
28
+ console.print(f'[bold red]Error: {e!s}[/]')
29
+ raise Exit(code=1) from e
30
+
31
+
32
+ def resolve_period(year: int | None, month: int | None) -> tuple[date, date]:
33
+ now = datetime.now(UTC).astimezone()
34
+ target_year = year or now.year
35
+ target_month = month or now.month
36
+
37
+ start, end = get_month_range(target_year, target_month)
38
+ return start, end
@@ -0,0 +1,3 @@
1
+ from .summary_table import render_summary_table
2
+
3
+ __all__ = ('render_summary_table',)
@@ -0,0 +1,44 @@
1
+ from decimal import Decimal
2
+
3
+ from rich.table import Column, Table
4
+
5
+ from skypro.domain.enums import WorkType
6
+ from skypro.domain.models import WorkReportItem
7
+ from skypro.utils import format_price
8
+
9
+ work_type_translate_map = {
10
+ WorkType.HOMEWORK: 'Домашние работы',
11
+ WorkType.CONSULTATION: 'Индивидуальные консультации',
12
+ WorkType.LIVE: 'Групповые занятия',
13
+ WorkType.DIPLOMA: 'Дипломные работы',
14
+ WorkType.COURSEWORK: 'Курсовые работы',
15
+ }
16
+
17
+
18
+ def render_summary_table(
19
+ report: list[WorkReportItem],
20
+ total: Decimal,
21
+ total_after_tax: Decimal,
22
+ ) -> Table:
23
+ table = Table(
24
+ Column('Тип', style='cyan'),
25
+ Column('Количество', style='magenta', justify='center'),
26
+ Column('Оплата', style='magenta', justify='right'),
27
+ title='Статистика работ за месяц',
28
+ )
29
+
30
+ for item in report:
31
+ if not item.quantity:
32
+ continue
33
+
34
+ table.add_row(
35
+ work_type_translate_map[item.work_type],
36
+ str(item.quantity),
37
+ format_price(item.price),
38
+ )
39
+
40
+ table.add_section()
41
+ table.add_row('Итого', '', format_price(total))
42
+ table.add_row('После уплаты налога', '', format_price(total_after_tax))
43
+
44
+ return table
File without changes
@@ -0,0 +1,51 @@
1
+ import logging
2
+ import logging.config
3
+ from functools import cache
4
+
5
+ from rich.console import Console
6
+ from rich.logging import RichHandler
7
+
8
+ from .settings import BASE_DIR, settings
9
+
10
+ LOG_DIR = BASE_DIR / 'logs'
11
+
12
+ LOGGING_CONFIG = {
13
+ 'version': 1,
14
+ 'disable_existing_loggers': False,
15
+ 'formatters': {
16
+ 'formatter': {
17
+ 'format': '%(asctime)s - %(levelname)s - %(message)s',
18
+ 'datefmt': '%Y-%m-%d %H:%M:%S',
19
+ },
20
+ },
21
+ 'handlers': {
22
+ 'file': {
23
+ 'class': 'logging.handlers.RotatingFileHandler',
24
+ 'filename': LOG_DIR / 'skypro.log',
25
+ 'maxBytes': 10 * 1024, # 10Mb
26
+ 'backupCount': 5,
27
+ 'formatter': 'formatter',
28
+ },
29
+ },
30
+ 'root': {
31
+ 'handlers': ['file'],
32
+ 'level': settings.log_level,
33
+ },
34
+ 'loggers': {
35
+ 'httpx': {'handlers': ['file'], 'level': logging.WARNING, 'propagate': True},
36
+ },
37
+ }
38
+
39
+
40
+ @cache
41
+ def configure_logging() -> None:
42
+ LOG_DIR.mkdir(exist_ok=True)
43
+ logging.config.dictConfig(LOGGING_CONFIG)
44
+
45
+
46
+ def enable_console_logging(console: Console) -> None:
47
+ root_logger = logging.getLogger()
48
+ console_handler = RichHandler(
49
+ console=console, rich_tracebacks=True, show_time=False
50
+ )
51
+ root_logger.addHandler(console_handler)
@@ -0,0 +1,29 @@
1
+ from pathlib import Path
2
+ from typing import Literal
3
+
4
+ from pydantic import BaseModel, Field
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+ BASE_DIR = Path(__file__).resolve().parent.parent
8
+
9
+
10
+ class SkyProSettings(BaseModel):
11
+ email: str
12
+ password: str
13
+
14
+
15
+ class Settings(BaseSettings):
16
+ tax_percent: float = 6.0
17
+ default_request_timeout: int = 3
18
+ log_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO'
19
+
20
+ skypro: SkyProSettings = Field(default_factory=SkyProSettings)
21
+
22
+ model_config = SettingsConfigDict(
23
+ env_nested_delimiter='__',
24
+ env_file='.env',
25
+ extra='ignore',
26
+ )
27
+
28
+
29
+ settings = Settings()
File without changes
@@ -0,0 +1,9 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class WorkType(StrEnum):
5
+ HOMEWORK = 'homework'
6
+ COURSEWORK = 'coursework'
7
+ DIPLOMA = 'diploma'
8
+ LIVE = 'live'
9
+ CONSULTATION = 'consultation'
@@ -0,0 +1,10 @@
1
+ class DomainError(Exception): ...
2
+
3
+
4
+ class InvalidPriceError(DomainError): ...
5
+
6
+
7
+ class InvalidQuantityError(DomainError): ...
8
+
9
+
10
+ class MissingWorkPriceError(DomainError): ...
@@ -0,0 +1,33 @@
1
+ from dataclasses import dataclass
2
+ from decimal import Decimal
3
+
4
+ from .enums import WorkType
5
+ from .errors import InvalidPriceError, InvalidQuantityError
6
+
7
+
8
+ @dataclass(slots=True, frozen=True)
9
+ class WorkPrice:
10
+ work_type: WorkType
11
+ price: Decimal
12
+
13
+ def __post_init__(self) -> None:
14
+ if self.price <= Decimal(0):
15
+ raise InvalidPriceError
16
+
17
+
18
+ @dataclass(slots=True, frozen=True)
19
+ class WorkSummary:
20
+ work_type: WorkType
21
+ quantity: int
22
+
23
+ def __post_init__(self) -> None:
24
+ if self.quantity < 0:
25
+ raise InvalidQuantityError
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class WorkReportItem:
30
+ work_type: WorkType
31
+ quantity: int
32
+ price: Decimal
33
+ total: Decimal
@@ -0,0 +1,37 @@
1
+ from decimal import Decimal
2
+
3
+ from skypro.config.settings import settings
4
+ from skypro.domain.errors import MissingWorkPriceError
5
+ from skypro.domain.models import WorkPrice, WorkReportItem, WorkSummary
6
+
7
+
8
+ def build_work_report(
9
+ prices: list[WorkPrice], summaries: list[WorkSummary]
10
+ ) -> list[WorkReportItem]:
11
+ price_map = {work_price.work_type: work_price.price for work_price in prices}
12
+
13
+ work_reports = []
14
+ for summary in summaries:
15
+ work_price = price_map.get(summary.work_type)
16
+ if work_price is None:
17
+ raise MissingWorkPriceError
18
+
19
+ work_reports.append(
20
+ WorkReportItem(
21
+ work_type=summary.work_type,
22
+ quantity=summary.quantity,
23
+ price=work_price,
24
+ total=work_price * summary.quantity,
25
+ )
26
+ )
27
+
28
+ return work_reports
29
+
30
+
31
+ def calculate_total(report: list[WorkReportItem]) -> Decimal:
32
+ return sum((item.total for item in report), Decimal(0))
33
+
34
+
35
+ def apply_tax(amount: Decimal) -> Decimal:
36
+ tax_rate = Decimal(str(settings.tax_percent)) / Decimal(100)
37
+ return amount * (Decimal(1) - tax_rate)
File without changes
File without changes
@@ -0,0 +1,31 @@
1
+ from typing import Any
2
+ from urllib.parse import urljoin
3
+
4
+ from httpx import Cookies, Response
5
+
6
+ from skypro.infra.http.client import HttpClient
7
+
8
+
9
+ class BaseApiClient:
10
+ base_url: str
11
+
12
+ def __init__(self, http_client: HttpClient) -> None:
13
+ self._http = http_client
14
+
15
+ @property
16
+ def cookies(self) -> Cookies:
17
+ return self._http.client.cookies
18
+
19
+ async def _get(self, url: str, **kwargs: Any) -> Response:
20
+ return await self._request('GET', url, **kwargs)
21
+
22
+ async def _post(self, url: str, **kwargs: Any) -> Response:
23
+ return await self._request('POST', url, **kwargs)
24
+
25
+ async def _request(self, method: str, path: str, **kwargs: Any) -> Response:
26
+ return await self._http.request(
27
+ method=method, url=self._build_url(path), **kwargs
28
+ )
29
+
30
+ def _build_url(self, path: str) -> str:
31
+ return urljoin(self.base_url, path)
@@ -0,0 +1,4 @@
1
+ class ApiError(Exception): ...
2
+
3
+
4
+ class AuthenticationError(ApiError): ...
@@ -0,0 +1,78 @@
1
+ import logging
2
+ from datetime import date
3
+
4
+ from skypro.config.settings import settings
5
+ from skypro.infra.api.base import BaseApiClient
6
+ from skypro.infra.api.errors import AuthenticationError
7
+ from skypro.infra.http.client import HttpClient
8
+
9
+ from .dto import AccountDataResponse
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class SkyProClient(BaseApiClient):
15
+ base_url = 'https://operation-planning.sky.pro'
16
+ login_url = '/careusers/login/'
17
+
18
+ def __init__(self, http_client: HttpClient) -> None:
19
+ super().__init__(http_client)
20
+ self._is_authenticated = False
21
+
22
+ @property
23
+ def is_authenticated(self) -> bool:
24
+ return self._is_authenticated
25
+
26
+ async def get_account_data(
27
+ self, start_date: date, end_date: date
28
+ ) -> AccountDataResponse:
29
+ await self.login()
30
+
31
+ logger.info('Fetching account data from %s to %s', start_date, end_date)
32
+
33
+ response = await self._get(
34
+ '/mentor-cabinet/api/data/',
35
+ params={
36
+ 'start_date': start_date.strftime('%Y-%m-%d'),
37
+ 'end_date': end_date.strftime('%Y-%m-%d'),
38
+ },
39
+ )
40
+
41
+ return AccountDataResponse.model_validate(response.json())
42
+
43
+ async def login(self) -> None:
44
+ if self._is_authenticated:
45
+ logger.debug('SkyPro already authenticated')
46
+ return
47
+
48
+ logger.info('Authenticating in SkyPro')
49
+
50
+ csrf_token = await self._get_csrf_token()
51
+
52
+ response = await self._post(
53
+ self.login_url,
54
+ data={
55
+ 'email': settings.skypro.email,
56
+ 'password': settings.skypro.password,
57
+ 'csrfmiddlewaretoken': csrf_token,
58
+ },
59
+ headers={'Referer': str(self.cookies)},
60
+ )
61
+
62
+ if response.url.path == self.login_url:
63
+ raise AuthenticationError('Authentication failed')
64
+
65
+ self._is_authenticated = True
66
+
67
+ logger.info('SkyPro authentication successful')
68
+
69
+ async def _get_csrf_token(self) -> str:
70
+ logger.debug('Fetching csrf token')
71
+
72
+ await self._get(self.login_url)
73
+
74
+ csrf_token = self.cookies.get('csrftoken')
75
+ if csrf_token is None:
76
+ raise AuthenticationError('CSRF token not found')
77
+
78
+ return csrf_token
@@ -0,0 +1,13 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class ServiceSummary(BaseModel, populate_by_name=True):
5
+ homework: int = Field(0, alias='ДЗ', ge=0)
6
+ coursework: int = Field(0, alias='КР', ge=0)
7
+ diploma: int = Field(0, alias='ДР', ge=0)
8
+ live: int = Field(0, alias='Лайв', ge=0)
9
+ individual_consultation: int = Field(0, alias='ИК', ge=0)
10
+
11
+
12
+ class AccountDataResponse(BaseModel):
13
+ services_summary: ServiceSummary
@@ -0,0 +1,64 @@
1
+ import logging
2
+ from types import TracebackType
3
+ from typing import Any, Self
4
+
5
+ import httpx
6
+ from httpx import AsyncClient, Request, Response
7
+
8
+ from skypro.config.settings import settings
9
+
10
+ from .errors import HttpError
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class HttpClient:
16
+ def __init__(self, timeout: int | None = None) -> None:
17
+ self._client = AsyncClient(
18
+ timeout=timeout or settings.default_request_timeout,
19
+ follow_redirects=True,
20
+ event_hooks={'request': [_log_request], 'response': [_log_response]},
21
+ )
22
+
23
+ async def __aenter__(self) -> Self:
24
+ return self
25
+
26
+ async def __aexit__(
27
+ self,
28
+ exc_type: type[BaseException] | None,
29
+ exc_val: BaseException | None,
30
+ exc_tb: TracebackType | None,
31
+ ) -> None:
32
+ await self._client.aclose()
33
+
34
+ @property
35
+ def client(self) -> AsyncClient:
36
+ return self._client
37
+
38
+ async def request(self, method: str, url: str, **kwargs: Any) -> Response:
39
+ try:
40
+ response = await self._client.request(method, url, **kwargs)
41
+ response.raise_for_status()
42
+ except httpx.HTTPStatusError as e:
43
+ raise HttpError(f'HTTP {e.response.status_code}: {method} {url}') from e
44
+ except httpx.HTTPError as e:
45
+ raise HttpError(f'Network error: {method} {url}') from e
46
+ else:
47
+ return response
48
+
49
+
50
+ async def _log_request(request: Request) -> None: # noqa: RUF029
51
+ logger.info(
52
+ 'HTTP request: %s %s',
53
+ request.method,
54
+ request.url,
55
+ )
56
+
57
+
58
+ async def _log_response(response: Response) -> None: # noqa: RUF029
59
+ logger.info(
60
+ 'HTTP response: %s %s -> %s',
61
+ response.request.method,
62
+ response.request.url,
63
+ response.status_code,
64
+ )
@@ -0,0 +1 @@
1
+ class HttpError(Exception): ...
@@ -0,0 +1,4 @@
1
+ from .summary import get_summary
2
+ from .work import get_works_prices
3
+
4
+ __all__ = ('get_summary', 'get_works_prices')
@@ -0,0 +1,7 @@
1
+ class RepositoryError(Exception): ...
2
+
3
+
4
+ class PriceLoadError(RepositoryError): ...
5
+
6
+
7
+ class SummaryLoadError(RepositoryError): ...
@@ -0,0 +1,30 @@
1
+ from datetime import date
2
+
3
+ from pydantic import ValidationError
4
+
5
+ from skypro.domain.enums import WorkType
6
+ from skypro.domain.models import WorkSummary
7
+ from skypro.infra.api.errors import ApiError
8
+ from skypro.infra.api.skypro.client import SkyProClient
9
+ from skypro.infra.http.client import HttpClient
10
+ from skypro.infra.http.errors import HttpError
11
+ from skypro.infra.repositories.errors import SummaryLoadError
12
+
13
+
14
+ async def get_summary(start_date: date, end_date: date) -> list[WorkSummary]:
15
+ try:
16
+ async with HttpClient() as http_client:
17
+ skypro = SkyProClient(http_client)
18
+ account_data = await skypro.get_account_data(start_date, end_date)
19
+ except (HttpError, ApiError, ValidationError) as e:
20
+ raise SummaryLoadError from e
21
+
22
+ return [
23
+ WorkSummary(WorkType.HOMEWORK, account_data.services_summary.homework),
24
+ WorkSummary(WorkType.COURSEWORK, account_data.services_summary.coursework),
25
+ WorkSummary(WorkType.DIPLOMA, account_data.services_summary.diploma),
26
+ WorkSummary(WorkType.LIVE, account_data.services_summary.live),
27
+ WorkSummary(
28
+ WorkType.CONSULTATION, account_data.services_summary.individual_consultation
29
+ ),
30
+ ]
@@ -0,0 +1,27 @@
1
+ import json
2
+ from decimal import Decimal, InvalidOperation
3
+
4
+ from skypro.config.settings import BASE_DIR
5
+ from skypro.domain.enums import WorkType
6
+ from skypro.domain.models import WorkPrice
7
+
8
+ from .errors import PriceLoadError
9
+
10
+
11
+ def get_works_prices() -> list[WorkPrice]:
12
+ try:
13
+ with (BASE_DIR / 'work_prices.json').open() as f:
14
+ data = json.load(f)
15
+ except (FileNotFoundError, json.JSONDecodeError) as e:
16
+ raise PriceLoadError from e
17
+
18
+ if not isinstance(data, dict):
19
+ raise PriceLoadError
20
+
21
+ try:
22
+ return [
23
+ WorkPrice(work_type=WorkType(work_type), price=Decimal(str(price)))
24
+ for work_type, price in data.items()
25
+ ]
26
+ except (ValueError, InvalidOperation) as e:
27
+ raise PriceLoadError from e
@@ -0,0 +1,11 @@
1
+ 2026-05-04 23:05:39 - INFO - HTTP request: GET https://operation-planning.sky.pro/careusers/login/
2
+ 2026-05-04 23:05:39 - WARNING - Executing <Task pending name='Task-3' coro=<get_summary() running at /Users/vadim/PycharmProjects/skypro-money/src/skypro/infra/repositories/summary.py:18> cb=[gather.<locals>._done_callback() at /Users/vadim/.pyenv/versions/3.13.2/lib/python3.13/asyncio/tasks.py:820] created at /Users/vadim/.pyenv/versions/3.13.2/lib/python3.13/asyncio/tasks.py:748> took 0.234 seconds
3
+ 2026-05-04 23:05:39 - INFO - HTTP response: GET https://operation-planning.sky.pro/careusers/login/ -> 200
4
+ 2026-05-04 23:05:39 - INFO - HTTP request: POST https://operation-planning.sky.pro/careusers/login/
5
+ 2026-05-04 23:05:40 - INFO - HTTP response: POST https://operation-planning.sky.pro/careusers/login/ -> 302
6
+ 2026-05-04 23:05:40 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/
7
+ 2026-05-04 23:05:40 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/ -> 200
8
+ 2026-05-04 23:05:40 - INFO - SkyPro authentication successful
9
+ 2026-05-04 23:05:40 - INFO - Fetching account data from 2026-04-01 to 2026-04-30
10
+ 2026-05-04 23:05:40 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-04-01&end_date=2026-04-30
11
+ 2026-05-04 23:05:40 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-04-01&end_date=2026-04-30 -> 200
@@ -0,0 +1,100 @@
1
+ 2026-05-04 21:51:43 - INFO - Authenticating in SkyPro
2
+ 2026-05-04 21:51:43 - INFO - HTTP request: GET https://operation-planning.sky.pro/careusers/login/
3
+ 2026-05-04 21:51:43 - INFO - HTTP response: GET https://operation-planning.sky.pro/careusers/login/ -> 200
4
+ 2026-05-04 21:51:43 - INFO - HTTP request: POST https://operation-planning.sky.pro/careusers/login/
5
+ 2026-05-04 21:51:44 - INFO - HTTP response: POST https://operation-planning.sky.pro/careusers/login/ -> 302
6
+ 2026-05-04 21:51:44 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/
7
+ 2026-05-04 21:51:44 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/ -> 200
8
+ 2026-05-04 21:51:44 - INFO - SkyPro authentication successful
9
+ 2026-05-04 21:51:44 - INFO - Fetching account data from 2026-05-01 to 2026-05-31
10
+ 2026-05-04 21:51:44 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-05-01&end_date=2026-05-31
11
+ 2026-05-04 21:51:44 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-05-01&end_date=2026-05-31 -> 200
12
+ 2026-05-04 22:01:00 - INFO - Authenticating in SkyPro
13
+ 2026-05-04 22:01:00 - INFO - HTTP request: GET https://operation-planning.sky.pro/careusers/login/
14
+ 2026-05-04 22:01:00 - INFO - HTTP response: GET https://operation-planning.sky.pro/careusers/login/ -> 200
15
+ 2026-05-04 22:01:00 - INFO - HTTP request: POST https://operation-planning.sky.pro/careusers/login/
16
+ 2026-05-04 22:01:01 - INFO - HTTP response: POST https://operation-planning.sky.pro/careusers/login/ -> 302
17
+ 2026-05-04 22:01:01 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/
18
+ 2026-05-04 22:01:01 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/ -> 200
19
+ 2026-05-04 22:01:01 - INFO - SkyPro authentication successful
20
+ 2026-05-04 22:01:01 - INFO - Fetching account data from 2026-05-01 to 2026-05-31
21
+ 2026-05-04 22:01:01 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-05-01&end_date=2026-05-31
22
+ 2026-05-04 22:01:01 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-05-01&end_date=2026-05-31 -> 200
23
+ 2026-05-04 22:05:36 - INFO - Authenticating in SkyPro
24
+ 2026-05-04 22:05:36 - INFO - HTTP request: GET https://operation-planning.sky.pro/careusers/login/
25
+ 2026-05-04 22:05:37 - INFO - HTTP response: GET https://operation-planning.sky.pro/careusers/login/ -> 200
26
+ 2026-05-04 22:05:37 - INFO - HTTP request: POST https://operation-planning.sky.pro/careusers/login/
27
+ 2026-05-04 22:05:37 - INFO - HTTP response: POST https://operation-planning.sky.pro/careusers/login/ -> 302
28
+ 2026-05-04 22:05:37 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/
29
+ 2026-05-04 22:05:38 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/ -> 200
30
+ 2026-05-04 22:05:38 - INFO - SkyPro authentication successful
31
+ 2026-05-04 22:05:38 - INFO - Fetching account data from 2026-05-01 to 2026-05-31
32
+ 2026-05-04 22:05:38 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-05-01&end_date=2026-05-31
33
+ 2026-05-04 22:05:38 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-05-01&end_date=2026-05-31 -> 200
34
+ 2026-05-04 22:05:52 - INFO - Authenticating in SkyPro
35
+ 2026-05-04 22:05:52 - INFO - HTTP request: GET https://operation-planning.sky.pro/careusers/login/
36
+ 2026-05-04 22:05:53 - INFO - HTTP response: GET https://operation-planning.sky.pro/careusers/login/ -> 200
37
+ 2026-05-04 22:05:53 - INFO - HTTP request: POST https://operation-planning.sky.pro/careusers/login/
38
+ 2026-05-04 22:05:53 - INFO - HTTP response: POST https://operation-planning.sky.pro/careusers/login/ -> 302
39
+ 2026-05-04 22:05:53 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/
40
+ 2026-05-04 22:05:53 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/ -> 200
41
+ 2026-05-04 22:05:53 - INFO - SkyPro authentication successful
42
+ 2026-05-04 22:05:53 - INFO - Fetching account data from 2026-05-01 to 2026-05-31
43
+ 2026-05-04 22:05:53 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-05-01&end_date=2026-05-31
44
+ 2026-05-04 22:05:54 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-05-01&end_date=2026-05-31 -> 200
45
+ 2026-05-04 22:24:16 - INFO - Authenticating in SkyPro
46
+ 2026-05-04 22:24:16 - INFO - HTTP request: GET https://operation-planning.sky.pro/careusers/login/
47
+ 2026-05-04 22:24:16 - INFO - HTTP response: GET https://operation-planning.sky.pro/careusers/login/ -> 200
48
+ 2026-05-04 22:24:16 - INFO - HTTP request: POST https://operation-planning.sky.pro/careusers/login/
49
+ 2026-05-04 22:24:16 - INFO - HTTP response: POST https://operation-planning.sky.pro/careusers/login/ -> 302
50
+ 2026-05-04 22:24:16 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/
51
+ 2026-05-04 22:24:16 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/ -> 200
52
+ 2026-05-04 22:24:16 - INFO - SkyPro authentication successful
53
+ 2026-05-04 22:24:16 - INFO - Fetching account data from 2026-05-01 to 2026-05-31
54
+ 2026-05-04 22:24:16 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-05-01&end_date=2026-05-31
55
+ 2026-05-04 22:24:16 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-05-01&end_date=2026-05-31 -> 200
56
+ 2026-05-04 22:31:14 - INFO - Authenticating in SkyPro
57
+ 2026-05-04 22:31:14 - INFO - HTTP request: GET https://operation-planning.sky.pro/careusers/login/
58
+ 2026-05-04 22:31:14 - INFO - HTTP response: GET https://operation-planning.sky.pro/careusers/login/ -> 200
59
+ 2026-05-04 22:31:14 - INFO - HTTP request: POST https://operation-planning.sky.pro/careusers/login/
60
+ 2026-05-04 22:31:14 - INFO - HTTP response: POST https://operation-planning.sky.pro/careusers/login/ -> 302
61
+ 2026-05-04 22:31:14 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/
62
+ 2026-05-04 22:31:14 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/ -> 200
63
+ 2026-05-04 22:31:14 - INFO - SkyPro authentication successful
64
+ 2026-05-04 22:31:14 - INFO - Fetching account data from 2026-04-01 to 2026-04-30
65
+ 2026-05-04 22:31:14 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-04-01&end_date=2026-04-30
66
+ 2026-05-04 22:31:14 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-04-01&end_date=2026-04-30 -> 200
67
+ 2026-05-04 22:31:17 - INFO - Authenticating in SkyPro
68
+ 2026-05-04 22:31:17 - INFO - HTTP request: GET https://operation-planning.sky.pro/careusers/login/
69
+ 2026-05-04 22:31:17 - INFO - HTTP response: GET https://operation-planning.sky.pro/careusers/login/ -> 200
70
+ 2026-05-04 22:31:17 - INFO - HTTP request: POST https://operation-planning.sky.pro/careusers/login/
71
+ 2026-05-04 22:31:17 - INFO - HTTP response: POST https://operation-planning.sky.pro/careusers/login/ -> 302
72
+ 2026-05-04 22:31:17 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/
73
+ 2026-05-04 22:31:17 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/ -> 200
74
+ 2026-05-04 22:31:17 - INFO - SkyPro authentication successful
75
+ 2026-05-04 22:31:17 - INFO - Fetching account data from 2026-04-01 to 2026-04-30
76
+ 2026-05-04 22:31:17 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-04-01&end_date=2026-04-30
77
+ 2026-05-04 22:31:18 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-04-01&end_date=2026-04-30 -> 200
78
+ 2026-05-04 22:33:28 - INFO - Authenticating in SkyPro
79
+ 2026-05-04 22:33:28 - INFO - HTTP request: GET https://operation-planning.sky.pro/careusers/login/
80
+ 2026-05-04 22:33:29 - INFO - HTTP response: GET https://operation-planning.sky.pro/careusers/login/ -> 200
81
+ 2026-05-04 22:33:29 - INFO - HTTP request: POST https://operation-planning.sky.pro/careusers/login/
82
+ 2026-05-04 22:33:29 - INFO - HTTP response: POST https://operation-planning.sky.pro/careusers/login/ -> 302
83
+ 2026-05-04 22:33:29 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/
84
+ 2026-05-04 22:33:29 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/ -> 200
85
+ 2026-05-04 22:33:29 - INFO - SkyPro authentication successful
86
+ 2026-05-04 22:33:29 - INFO - Fetching account data from 2026-04-01 to 2026-04-30
87
+ 2026-05-04 22:33:29 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-04-01&end_date=2026-04-30
88
+ 2026-05-04 22:33:29 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-04-01&end_date=2026-04-30 -> 200
89
+ 2026-05-04 22:33:41 - INFO - Authenticating in SkyPro
90
+ 2026-05-04 22:33:41 - INFO - HTTP request: GET https://operation-planning.sky.pro/careusers/login/
91
+ 2026-05-04 22:33:41 - INFO - HTTP response: GET https://operation-planning.sky.pro/careusers/login/ -> 200
92
+ 2026-05-04 22:33:41 - INFO - HTTP request: POST https://operation-planning.sky.pro/careusers/login/
93
+ 2026-05-04 22:33:41 - INFO - HTTP response: POST https://operation-planning.sky.pro/careusers/login/ -> 302
94
+ 2026-05-04 22:33:41 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/
95
+ 2026-05-04 22:33:41 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/ -> 200
96
+ 2026-05-04 22:33:41 - INFO - SkyPro authentication successful
97
+ 2026-05-04 22:33:41 - INFO - Fetching account data from 2026-04-01 to 2026-04-30
98
+ 2026-05-04 22:33:41 - INFO - HTTP request: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-04-01&end_date=2026-04-30
99
+ 2026-05-04 22:33:41 - INFO - HTTP response: GET https://operation-planning.sky.pro/mentor-cabinet/api/data/?start_date=2026-04-01&end_date=2026-04-30 -> 200
100
+ 2026-05-04 23:05:39 - INFO - Authenticating in SkyPro
@@ -0,0 +1,14 @@
1
+ import locale
2
+
3
+ from skypro.cli.app import app
4
+ from skypro.config.logging import configure_logging
5
+
6
+
7
+ def main() -> None:
8
+ locale.setlocale(locale.LC_ALL, 'ru_RU.UTF-8')
9
+ configure_logging()
10
+ app()
11
+
12
+
13
+ if __name__ == '__main__':
14
+ main()
@@ -0,0 +1,3 @@
1
+ from .get_summary_info import SummaryInfo, get_summary_info
2
+
3
+ __all__ = ('SummaryInfo', 'get_summary_info')
@@ -0,0 +1,28 @@
1
+ import asyncio
2
+ from asyncio import gather
3
+ from datetime import date
4
+ from decimal import Decimal
5
+ from typing import NamedTuple
6
+
7
+ from skypro.domain.models import WorkReportItem
8
+ from skypro.domain.services import apply_tax, build_work_report, calculate_total
9
+ from skypro.infra.repositories import get_summary, get_works_prices
10
+
11
+
12
+ class SummaryInfo(NamedTuple):
13
+ report: list[WorkReportItem]
14
+ total: Decimal
15
+ total_after_tax: Decimal
16
+
17
+
18
+ async def get_summary_info(start_date: date, end_date: date) -> SummaryInfo:
19
+ work_prices, work_summaries = await gather(
20
+ asyncio.to_thread(get_works_prices),
21
+ get_summary(start_date, end_date),
22
+ )
23
+
24
+ report = build_work_report(work_prices, work_summaries)
25
+ total = calculate_total(report)
26
+ total_after_tax = apply_tax(total)
27
+
28
+ return SummaryInfo(report, total, total_after_tax)
@@ -0,0 +1,14 @@
1
+ import locale
2
+ from calendar import monthrange
3
+ from datetime import date
4
+ from decimal import Decimal
5
+
6
+
7
+ def get_month_range(year: int, month: int) -> tuple[date, date]:
8
+ first_day = 1
9
+ _, last_day = monthrange(year, month)
10
+ return date(year, month, first_day), date(year, month, last_day)
11
+
12
+
13
+ def format_price(price: Decimal) -> str:
14
+ return locale.currency(price, grouping=True)
@@ -0,0 +1,7 @@
1
+ {
2
+ "homework": 319,
3
+ "coursework": 1064,
4
+ "diploma": 1596,
5
+ "live": 1596,
6
+ "consultation": 1064
7
+ }