Coverage for src/gitlabracadabra/objects/user.py: 88%
34 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-10 17:02 +0100
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-10 17:02 +0100
1#
2# Copyright (C) 2019-2025 Mathieu Parent <math.parent@gmail.com>
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU Lesser General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
17import logging
18from typing import ClassVar
20from gitlabracadabra.objects.object import GitLabracadabraObject
22logger = logging.getLogger(__name__)
25class GitLabracadabraUser(GitLabracadabraObject):
26 EXAMPLE_YAML_HEADER: ClassVar[str] = "mmyuser:\n type: user\n"
27 DOC: ClassVar[list[str]] = [
28 "# User lifecycle",
29 "gitlab_id",
30 "create_object",
31 "delete_object",
32 "# Edit",
33 "## Account",
34 "name",
35 # 'username',
36 "email",
37 "skip_confirmation",
38 "skip_reconfirmation",
39 "public_email",
40 "state",
41 "## Password",
42 "password",
43 "reset_password",
44 "force_random_password",
45 "## Access",
46 "projects_limit",
47 "can_create_group",
48 "admin",
49 "external",
50 "provider",
51 "extern_uid",
52 "## Limits",
53 "shared_runners_minutes_limit",
54 "extra_shared_runners_minutes_limit",
55 "## Profile",
56 "avatar",
57 "skype",
58 "linkedin",
59 "twitter",
60 "website_url",
61 "location",
62 "organization",
63 "bio",
64 "private_profile",
65 "note",
66 ]
67 SCHEMA: ClassVar[dict] = {
68 "$schema": "http://json-schema.org/draft-04/schema#",
69 "title": "User",
70 "type": "object",
71 "properties": {
72 # Standard properties
73 "gitlab_id": {
74 "type": "string",
75 "description": "GitLab id",
76 "_example": "gitlab",
77 "_doc_link": "action_file.md#gitlab_id",
78 },
79 "create_object": {
80 "type": "boolean",
81 "description": "Create object if it does not exists",
82 },
83 "delete_object": {
84 "type": "boolean",
85 "description": "Delete object if it exists",
86 },
87 # From https://docs.gitlab.com/ee/api/users.html#user-creation
88 # 'username': {
89 # 'type': 'string',
90 # 'description': 'Username',
91 # },
92 "name": {
93 "type": "string",
94 "description": "Name",
95 },
96 "email": {
97 "type": "string",
98 "description": "Email",
99 },
100 "skip_confirmation": {
101 "type": "boolean",
102 "description": "Skip confirmation and assume e-mail is verified",
103 },
104 "skip_reconfirmation": {
105 "type": "boolean",
106 "description": "Skip reconfirmation",
107 },
108 "public_email": {
109 "type": "string",
110 "description": "The public email of the user",
111 },
112 "state": {
113 "type": "string",
114 "description": "User state",
115 "enum": [
116 "active",
117 "banned",
118 "blocked",
119 "blocked_pending_approval",
120 "deactivated",
121 "ldap_blocked",
122 ],
123 },
124 "password": {
125 "type": "string",
126 "description": "Password",
127 },
128 "reset_password": {
129 "type": "boolean",
130 "description": "Send user password reset link",
131 },
132 "force_random_password": {
133 "type": "boolean",
134 "description": "Set user password to a random value ",
135 },
136 "projects_limit": {
137 "type": "integer",
138 "description": "Number of projects user can create",
139 "multipleOf": 1,
140 "minimum": 0,
141 },
142 "can_create_group": {
143 "type": "boolean",
144 "description": "User can create groups",
145 },
146 "admin": {
147 "type": "boolean",
148 "description": "User is admin",
149 },
150 "external": {
151 "type": "boolean",
152 "description": "Flags the user as external",
153 },
154 "provider": {
155 "type": "string",
156 "description": "External provider name",
157 },
158 "extern_uid": {
159 "type": "string",
160 "description": "External UID",
161 },
162 "shared_runners_minutes_limit": {
163 "type": "integer",
164 "description": "Pipeline minutes quota for this user",
165 "multipleOf": 1,
166 "minimum": 0,
167 },
168 "extra_shared_runners_minutes_limit": {
169 "type": "integer",
170 "description": "Extra pipeline minutes quota for this user",
171 "multipleOf": 1,
172 "minimum": 0,
173 },
174 "avatar": {
175 "type": "string",
176 "description": "Image file for user's avatar",
177 },
178 "skype": {
179 "type": "string",
180 "description": "Skype ID",
181 },
182 "linkedin": {
183 "type": "string",
184 "description": "LinkedIn",
185 },
186 "twitter": {
187 "type": "string",
188 "description": "Twitter account",
189 },
190 "website_url": {
191 "type": "string",
192 "description": "Website URL",
193 },
194 "location": {
195 "type": "string",
196 "description": "User's location",
197 },
198 "organization": {
199 "type": "string",
200 "description": "Organization name",
201 },
202 "bio": {
203 "type": "string",
204 "description": "User's biography",
205 },
206 "private_profile": {
207 "type": "boolean",
208 "description": "User's profile is private",
209 },
210 "note": {
211 "type": "string",
212 "description": "Admin note",
213 },
214 },
215 "additionalProperties": False,
216 "dependencies": {
217 "email": ["skip_reconfirmation"],
218 },
219 }
221 FIND_PARAM = "username"
223 CREATE_KEY = "username"
225 CREATE_PARAMS: ClassVar[list[str]] = [
226 "email",
227 "password",
228 "force_random_password",
229 "reset_password",
230 "skip_confirmation",
231 "name",
232 ]
234 IGNORED_PARAMS: ClassVar[list[str]] = [
235 "password",
236 "force_random_password",
237 "reset_password",
238 "skip_confirmation",
239 "skip_reconfirmation",
240 ]
242 """"_get_param()
244 Get a param value.
245 """
247 def _get_param(self, param_name):
248 if param_name == "admin":
249 param_name = "is_admin"
250 return super()._get_param(param_name)
252 """"_process_state()
254 Process the state param.
255 """
257 def _process_state(self, param_name, param_value, *, dry_run=False, skip_save=False):
258 assert param_name == "state" # noqa: S101
259 assert not skip_save # noqa: S101
261 current_value = getattr(self._obj, param_name)
262 if current_value != param_value: 262 ↛ exitline 262 didn't return from function '_process_state' because the condition on line 262 was always true
263 # From Gitlab's state machine
264 # https://gitlab.com/gitlab-org/gitlab/-/blob/8976bab138344e55e7feb1725cf63770d0a2741b/app/models/user.rb#L324-367
265 action = self._state_action(current_value, param_value)
266 if action is None: 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true
267 logger.warning(
268 "[%s] No action found to change param %s: %s -> %s (dry-run)",
269 self._name,
270 param_name,
271 current_value,
272 param_value,
273 )
274 elif dry_run: 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true
275 logger.info(
276 "[%s] NOT doing %s to change param %s: %s -> %s (dry-run)",
277 self._name,
278 action,
279 param_name,
280 current_value,
281 param_value,
282 )
283 else:
284 logger.info(
285 "[%s] Doing %s to change param %s: %s -> %s (dry-run)",
286 self._name,
287 action,
288 param_name,
289 current_value,
290 param_value,
291 )
292 getattr(self._obj, action)()
294 """"_state_action()
296 Get action.
297 """
299 def _state_action(self, current: str, target: str) -> str | None:
300 # https://gitlab.com/gitlab-org/gitlab/-/blob/1856858760a831a568d6ddae912ed1fc141d76cd/app/models/user.rb#L356-433
301 # and https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/api/users.rb
302 # (current, target): action
303 transitions = {
304 ("active", "blocked"): "block",
305 ("deactivated", "blocked"): "block",
306 ("ldap_blocked", "blocked"): "block",
307 ("blocked_pending_approval", "blocked"): "block",
308 ("blocked", "active"): "unblock",
309 ("blocked_pending_approval", "active"): "approve",
310 ("deactivated", "active"): "activate",
311 ("active", "banned"): "ban",
312 ("banned", "active"): "unban",
313 ("active", "deactivated"): "deactivate",
314 }
315 return transitions.get((current, target), None)