1"""GitLab Project model implementation.
2
3Implements :any:`/specs/spec_model_mapping` §2.2 for Project entity mapping.
4"""
5
6from __future__ import annotations
7
8from datetime import datetime
9import json
10from typing import Any, Dict, List
11
12import logging
13
14
15from pydantic import BaseModel, Field, field_validator
16
17from ..rendering.renderer_base import Renderer
18from .group import Group
19
20logger = logging.getLogger(__name__)
21
22
[docs]
23class Project(BaseModel):
24 """GitLab Project domain model.
25
26 Implements :any:`/specs/spec_model_mapping` §2.2, mapping the following fields:
27
28 * id (int)
29 * name (str)
30 * path_with_namespace (str, optional)
31 * default_branch (str, optional)
32 * last_activity_at (datetime) - Parsed to UTC
33 * namespace (Group) - namespace parsed as group if possible.
34 """
35
36 id: int
37 name: str
38 description: str | None = None
39 web_url: str | None = None
40 path: str | None = None
41 path_with_namespace: str | None = None
42 last_activity_at: datetime | None = None
43 default_branch: str | None = None
44 visibility: str | None = None
45 readme_url: str | None = None
46 avatar_url: str | None = None
47 namespace: Group | None = None
48
[docs]
49 @field_validator("id", mode="before")
50 @classmethod
51 def validate_int_fields(cls, v: Any) -> int | None:
52 """Convert string/integer mismatches for numeric fields."""
53 if v is None:
54 return None
55 return Renderer.safe_int(v, v)
56
[docs]
57 @field_validator("namespace", mode="before")
58 @classmethod
59 def validate_group_fields(cls, v: Any) -> Group | None:
60 """Convert group-dict to group."""
61 if v is None:
62 return None
63 try:
64 import logging
65
66 logging.debug(f"Validating namespace: {v}")
67 g = Group(**v)
68 logging.debug(f"Validated namespace: {g}")
69 return g
70 except Exception:
71 return None
72
[docs]
73 @field_validator("last_activity_at", mode="before")
74 @classmethod
75 def validate_datetime_fields(cls, v: Any) -> datetime | None:
76 """Convert string dates to datetime if needed."""
77 if v is None:
78 return None
79 if isinstance(v, str):
80 parsed_date = Renderer.parse_iso_date(v)
81 if parsed_date is not None:
82 return parsed_date
83 # Try common date formats as fallback
84 try:
85 for fmt in ["%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"]:
86 try:
87 return datetime.strptime(v, fmt)
88 except ValueError:
89 continue
90 except Exception:
91 pass
92 # If all parsing fails, return None
93 return None
94 return v
95
[docs]
96 def get_avatar_html(self, width: int = 16) -> str:
97 """Generate HTML for avatar image if available."""
98 if self.avatar_url:
99 return f"{{width={width}px}} "
100 return ""
101
[docs]
102 @classmethod
103 def from_api_json(cls, data: dict[str, Any]) -> Project:
104 """Create Project from API JSON with robust type handling."""
105 processed_data = {}
106 for key, value in data.items():
107 if key in ["id"]:
108 processed_data[key] = Renderer.safe_int(value, value)
109 elif key in ["last_activity_at"]:
110 if isinstance(value, str):
111 parsed_date = Renderer.parse_iso_date(value)
112 processed_data[key] = (
113 parsed_date if parsed_date is not None else value
114 )
115 else:
116 processed_data[key] = value
117 else:
118 processed_data[key] = value
119 return cls(**processed_data)
120
[docs]
121 def model_dump(self, **kwargs) -> dict[str, Any]:
122 """Override to include computed fields."""
123 data = super().model_dump(**kwargs)
124 # Add computed fields
125 data["avatar_html"] = self.get_avatar_html()
126 return data
127
128 model_config = {
129 "extra": "ignore",
130 "populate_by_name": True,
131 }
132
133
134__all__ = ["Project"]