Coverage for app / services / link_service.py: 100%

93 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-21 23:51 +0300

1from datetime import UTC, datetime, timedelta 

2 

3from sqlalchemy.ext.asyncio import AsyncSession 

4 

5from app.core.config import settings 

6from app.core.exceptions import ( 

7 AlreadyExistsError, 

8 NotFoundError, 

9 PermissionDeniedError 

10) 

11from app.models import Link 

12from app.repositories.link_repository import LinkRepository 

13from app.core.security import generate_short_code_n_times 

14 

15async def create_link_service( 

16 db: AsyncSession, 

17 long_url: str, 

18 auto_prolong: bool = False, 

19 alias: str | None = None, 

20 expires_at: datetime | None = None, 

21 user_id: int | None = None, 

22) -> Link: 

23 """ 

24 Перед созданием ссылки проверяет наличие указанной 

25 короткой ссылки в базе данных. 

26 

27 - Если ссылка существует и время её жизни не истекло, то вызывается 

28 исключение `AlreadyExistsError`. 

29 - Если обнаружена просроченная ссылка, то она помечается на удаление 

30 в текущей сессии, а занятый `alias` освобождается: пользователь 

31 сможет создать новую ссылку с таким `alias`. 

32 """ 

33 now = datetime.now(tz=UTC).replace(tzinfo=None) 

34 

35 if alias: 

36 existing_link = await LinkRepository.get_by_short_code(db, alias) 

37 if existing_link and existing_link.expires_at > now: 

38 raise AlreadyExistsError("Alias already exists") 

39 elif existing_link and existing_link.expires_at < now: 

40 await LinkRepository.delete(db, existing_link) 

41 await db.flush() 

42 short_code = alias 

43 else: 

44 short_code = await generate_short_code_n_times(db) 

45 

46 # устанавливаем дату истечения действия короткой ссылки, если пользователь её не указал 

47 if not expires_at: 

48 expires_at = now + timedelta( 

49 days=settings.link_expire_days 

50 ) 

51 

52 link = Link( 

53 long_url=long_url, 

54 user_id=user_id, 

55 short_code=short_code, 

56 expires_at=expires_at, 

57 auto_prolong=auto_prolong 

58 ) 

59 

60 try: 

61 db.add(link) 

62 await db.commit() 

63 await db.refresh(link) 

64 return link 

65 except Exception: 

66 await db.rollback() 

67 raise 

68 

69async def update_link_service( 

70 db: AsyncSession, 

71 current_short_code: str, 

72 user_id: int, 

73 alias: str | None = None 

74) -> Link: 

75 """ 

76 Обновляет короткий код для активных ссылок. 

77 

78 - Если ссылки с переданным коротким кодом не существует 

79 или срок действия ссылки прошёл, то вызывается исключение `NotFoundError`. 

80 - Если id пользователя, запрашивающего обновление ссылки, не совпадает с id 

81 пользователя в базе данных, то вызывается исключение `PermissionDeniedError`. 

82 - Если ссылка существует и время её жизни не истекло, то вызывается 

83 исключение `AlreadyExistsError`. 

84 - Если обнаружена просроченная ссылка, то она удаляется, а `alias` освобождается. 

85 """ 

86 now = datetime.now(tz=UTC).replace(tzinfo=None) 

87 existing_link = await LinkRepository.get_active_by_short_code(db, current_short_code, now) 

88 if not existing_link: 

89 raise NotFoundError(f"Short link not found") 

90 

91 if existing_link.user_id != user_id: 

92 raise PermissionDeniedError() 

93 

94 if alias: 

95 link = await LinkRepository.get_by_short_code(db, alias) 

96 if link and link.expires_at > now: 

97 raise AlreadyExistsError("Alias already exists") 

98 elif link and link.expires_at < now: 

99 await LinkRepository.delete(db, link) 

100 await db.flush() 

101 new_short_code = alias 

102 else: 

103 new_short_code = await generate_short_code_n_times(db) 

104 

105 try: 

106 await LinkRepository.update_short_code(db, current_short_code, new_short_code) 

107 await db.commit() 

108 await db.refresh(existing_link) 

109 return existing_link 

110 except Exception: 

111 await db.rollback() 

112 raise 

113 

114 

115async def redirect_link_service(db: AsyncSession, short_code: str) -> Link: 

116 """ 

117 Перед перенаправлением пользователя на исходный URL вызывает 

118 функцию из репозитория, которая обновляет дату истечения 

119 действия ссылки и количество переходов по ссылке. 

120 

121 - Если ссылки с переданным коротким кодом не существует 

122 или срок действия ссылки прошёл, то вызывается исключение `NotFoundError`. 

123 - Если у ссылки активен флаг автопродления и до даты истечения действия 

124 ссылки осталось меньше 15 дней, то время жизни ссылки увеличивается. 

125 """ 

126 now = datetime.now(tz=UTC).replace(tzinfo=None) 

127 new_exp_date = None 

128 

129 link = await LinkRepository.get_active_by_short_code(db, short_code, now) 

130 if not link: 

131 raise NotFoundError(f"Short link not found") 

132 

133 if link.auto_prolong and (link.expires_at - now) < timedelta(days=15): 

134 new_exp_date = now + timedelta(days=settings.link_expire_days) 

135 

136 try: 

137 await LinkRepository.update_on_redirect(db, short_code, now, new_exp_date) 

138 await db.commit() 

139 await db.refresh(link) 

140 return link 

141 except Exception: 

142 await db.rollback() 

143 raise 

144 

145 

146async def delete_link_service(db: AsyncSession, short_code: str, user_id: int) -> None: 

147 """ 

148 Удаляет ссылку из базы данных. 

149 

150 - Если ссылки с переданным коротким кодом не существует, то вызывается 

151 исключение `NotFoundError`. 

152 - Если пользователь пытается удалить чужую ссылку, то вызывается 

153 исключение `PermissionDeniedError`. 

154 """ 

155 link = await LinkRepository.get_by_short_code(db, short_code) 

156 if not link: 

157 raise NotFoundError(f"Short link not found") 

158 if link.user_id != user_id: 

159 raise PermissionDeniedError() 

160 try: 

161 await LinkRepository.delete(db, link) 

162 await db.commit() 

163 except Exception: 

164 await db.rollback() 

165 raise 

166 

167 

168async def get_stats_link_service(db: AsyncSession, short_code: str) -> dict: 

169 """ 

170 Возвращает статистику по активным ссылкам с указанным коротким кодом 

171 (исходный URL, дата создания, количество переходов, дата последнего перехода). 

172 

173 - Если ссылки с переданным коротким кодом не существует 

174 или срок действия ссылки прошёл, то вызывается исключение `NotFoundError`. 

175 """ 

176 now = datetime.now(tz=UTC).replace(tzinfo=None) 

177 link = await LinkRepository.get_active_by_short_code(db, short_code, now) 

178 if not link: 

179 raise NotFoundError(f"Short link not found") 

180 return { 

181 "long_url": link.long_url, 

182 "created_at": link.created_at, 

183 "visits_counter": link.visits_counter, 

184 "last_visited_at": link.last_visited_at, 

185 } 

186 

187async def get_by_long_url_link_service( 

188 db: AsyncSession, long_url: str, user_id: int | None = None 

189) -> list[Link]: 

190 """ 

191 Вовзращает список коротких ссылок для указанного исходного URL. 

192 

193 - Если ссылки с переданным коротким кодом не существует 

194 или срок действия ссылки прошёл, то вызывается исключение `NotFoundError`. 

195 """ 

196 now = datetime.now(tz=UTC).replace(tzinfo=None) 

197 links = await LinkRepository.get_active_by_long_url(db, long_url, now, user_id) 

198 if not links: 

199 raise NotFoundError("Short links for provided long url not found") 

200 return links