Coverage for seedboxsync/cli/commands/cmd_user.py: 90%

87 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-31 17:10 +0000

1# 

2# Copyright (C) 2015-2026 Guillaume Kulakowski <guillaume@kulakowski.fr> 

3# 

4# For the full copyright and license information, please view the LICENSE 

5# file that was distributed with this source code. 

6# 

7"""All commands related to the users operations & management.""" 

8 

9import click 

10from peewee import fn 

11from werkzeug.security import generate_password_hash 

12from seedboxsync.cli import Context, group, pass_context 

13from seedboxsync.core.database.models import User 

14 

15 

16@group("user", help="User operations & management for SeedboxSync frontend.") # type: ignore[untyped-decorator] 

17@pass_context 

18def cli(ctx: Context) -> None: 

19 """Empty function for Click sub commands.""" 

20 

21 

22@cli.command("list", help="List users.") # type: ignore[untyped-decorator] 

23@click.option("-n", "--number", type=int, default=10, help="Number of torrents to display.") 

24@click.option("-s", "--search", help="Term to search.") 

25@pass_context 

26def list_user(ctx: Context, number: int, search: str) -> None: 

27 """ 

28 List registered users from the database. 

29 

30 Queries user accounts with optional text filtering on usernames and renders 

31 a formatted table containing user metadata. 

32 

33 Args: 

34 ctx (Context): The CLI application context. 

35 number (int): Maximum number of users to display. Defaults to 10. 

36 search (str): Optional search term to filter users by username. 

37 """ 

38 # Build "where" expression 

39 conditions = [] 

40 if search: 

41 conditions.append(User.username.contains(search)) 

42 

43 # DB query 

44 query = ( 

45 User.select( 

46 User.id, 

47 User.username, 

48 User.email, 

49 User.origin, 

50 User.is_admin, 

51 fn.short_datetime(User.created), 

52 fn.short_datetime(User.last_login), 

53 ) 

54 .limit(number) 

55 .order_by(User.id.desc()) 

56 ) 

57 

58 # if "where" expression 

59 if conditions: 

60 query = query.where(*conditions) 

61 data = query.dicts() 

62 

63 click.echo( 

64 ctx.render( 

65 reversed(data), 

66 headers={ 

67 "id": "Id", 

68 "username": "Username", 

69 "email": "Email", 

70 "origin": "Origin", 

71 "is_admin": "Is Admin", 

72 "created": "Created", 

73 "last_login": "Last Login", 

74 }, 

75 ) 

76 ) 

77 

78 

79@cli.command("delete", help="Delete a user.") # type: ignore[untyped-decorator] 

80@click.option("--id", type=int, required=True, help="ID of the user to delete.") 

81@click.option("-y", "--yes", is_flag=True, help="Confirm deletion without prompting.") 

82@pass_context 

83def delete(ctx: Context, id: int, yes: bool) -> None: # noqa: A002 

84 """ 

85 Delete a user by their unique database identifier. 

86 

87 Args: 

88 ctx (Context): The CLI application context. 

89 id (int): Database identifier of the user to remove. 

90 yes (bool): Skip confirmation prompt if set to True. 

91 """ 

92 try: 

93 user = User.get_by_id(id) 

94 except User.DoesNotExist: # pyright: ignore [reportAttributeAccessIssue] 

95 click.secho(f"Error: User with ID {id} does not exist.", fg="red", err=True) 

96 return 

97 

98 if not yes and not click.confirm(f"Are you sure you want to delete user '{user.username}' (ID: {user.id})?"): 

99 click.echo("Operation canceled.") 

100 return 

101 

102 try: 

103 username = user.username 

104 user.delete_instance() 

105 click.secho(f"User '{username}' (ID: {id}) deleted successfully.", fg="green") 

106 except Exception as e: 

107 click.secho(f"Error: Failed to delete user {id}: {e}", fg="red", err=True) 

108 

109 

110@cli.command("add", help="Add a new user.") # type: ignore[untyped-decorator] 

111@click.option("-u", "--username", prompt="Username", help="Username for the new account.") 

112@click.option("-e", "--email", prompt="Email address", help="Unique email address.") 

113@click.option("-p", "--password", prompt=True, hide_input=True, confirmation_prompt=True, help="Password for the user account.") 

114@click.option("--is-admin/--no-is-admin", "is_admin", prompt="Grant admin privileges?", is_flag=True, help="Grant admin privileges to the user.") 

115@pass_context 

116def add(ctx: Context, username: str, email: str, password: str, is_admin: bool) -> None: 

117 """ 

118 Create a new user account in the database. 

119 

120 Accepts account details via command-line options or interactive prompts, 

121 hashes the password, and stores the user record. 

122 

123 Args: 

124 ctx (Context): The CLI application context. 

125 username (str): Unique username for the account. 

126 email (str): Unique email address. 

127 password (str): Plain-text password to hash and store. 

128 is_admin (bool): Whether the user should have admin privileges. 

129 """ 

130 # Verify if user already exists before attempting insertion 

131 if User.get_or_none((User.username == username) | (User.email == email)): 

132 click.secho( 

133 f"Error: A user with username '{username}' or email '{email}' already exists.", 

134 fg="red", 

135 err=True, 

136 ) 

137 return 

138 

139 try: 

140 user = User.create(username=username, email=email, password=generate_password_hash(password), is_admin=is_admin) 

141 click.secho(f"User '{user.username}' (ID: {user.id}) created successfully.", fg="green") 

142 except Exception as e: 

143 click.secho(f"Error: Failed to create user: {e}", fg="red", err=True) 

144 

145 

146@cli.command("edit", help="Edit an existing user.") # type: ignore[untyped-decorator] 

147@click.option("--id", type=int, required=True, help="ID of the user to edit.") 

148@click.option("-u", "--username", help="New username for the account.") 

149@click.option("-e", "--email", help="New email address.") 

150@click.option("-p", "--password", help="New password for the account.") 

151@click.option("--is-admin/--no-is-admin", "is_admin", default=None, help="Grant or revoke admin privileges.") 

152@pass_context 

153def edit(ctx: Context, id: int, username: str | None, email: str | None, password: str | None, is_admin: bool | None) -> None: # noqa: A002 

154 """ 

155 Update details for an existing user account. 

156 

157 Fetches the user by ID and updates only the provided fields. Password 

158 updates are automatically hashed before saving. 

159 

160 Args: 

161 ctx (Context): The CLI application context. 

162 id (int): Database identifier of the user to edit. 

163 username (str | None): New username to set. 

164 email (str | None): New email address to set. 

165 password (str | None): New plain-text password to hash and update. 

166 is_admin (bool | None): Whether the user should have admin privileges. 

167 """ 

168 try: 

169 user = User.get_by_id(id) 

170 except User.DoesNotExist: # pyright: ignore [reportAttributeAccessIssue] 

171 click.secho(f"Error: User with ID {id} does not exist.", fg="red", err=True) 

172 return 

173 

174 # Check unique constraint collisions if username or email is being updated 

175 if username and username != user.username: 

176 if User.get_or_none(User.username == username): 

177 click.secho(f"Error: Username '{username}' is already taken.", fg="red", err=True) 

178 return 

179 user.username = username 

180 

181 if email and email != user.email: 

182 if User.get_or_none(User.email == email): 

183 click.secho(f"Error: Email '{email}' is already taken.", fg="red", err=True) 

184 return 

185 user.email = email 

186 

187 if password: 

188 user.password = generate_password_hash(password) 

189 

190 if is_admin is not None: 

191 user.is_admin = is_admin 

192 

193 try: 

194 user.save() 

195 click.secho(f"User '{user.username}' (ID: {user.id}) updated successfully.", fg="green") 

196 except Exception as e: 

197 click.secho(f"Error: Failed to update user {id}: {e}", fg="red", err=True)