Skip to content

Commit 25eca71

Browse files
feat: add social endpoint for browsing projects, users, and tags (#625)
* feat: add social endpoint for browsing projects, users, and tags * chore: add review comments for socials controller and helpers refactor (pagination, schema counts) * fix(migrations):prevent accidental drop of project_database table and ensure downgrade restores it properly * refactor: move Followers import to top of file
1 parent 345b2d5 commit 25eca71

File tree

10 files changed

+458
-3
lines changed

10 files changed

+458
-3
lines changed

api_docs.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4218,6 +4218,57 @@ paths:
42184218
500:
42194219
description: "Internal Server Error"
42204220

4221+
"/socials":
4222+
get:
4223+
tags:
4224+
- socials
4225+
consumes:
4226+
- application/json
4227+
produces:
4228+
- application/json
4229+
parameters:
4230+
- in: header
4231+
name: Authorization
4232+
required: true
4233+
description: "Bearer [token]"
4234+
type: string
4235+
- in: query
4236+
name: entity
4237+
required: false
4238+
type: string
4239+
enum: [projects, users, tags]
4240+
- in: query
4241+
name: search
4242+
required: false
4243+
type: string
4244+
- in: query
4245+
name: filter
4246+
required: false
4247+
type: string
4248+
enum: [trending, recently_updated, newly_added]
4249+
- in: query
4250+
name: page
4251+
required: false
4252+
type: integer
4253+
minimum: 1
4254+
default: 1
4255+
- in: query
4256+
name: per_page
4257+
required: false
4258+
type: integer
4259+
minimum: 1
4260+
maximum: 100
4261+
default: 10
4262+
responses:
4263+
200:
4264+
description: "Successfully retrieved search results"
4265+
400:
4266+
description: "Bad request"
4267+
404:
4268+
description: "User not found"
4269+
500:
4270+
description: "Internal Server Error"
4271+
42214272

42224273
"/apps/{app_id}/domains":
42234274
post:

app/controllers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@
3232
from .activity_feed import ActivityFeedView
3333
from .tags import TagsView, TagsDetailView, TagFollowingView
3434
from .generic_search import GenericSearchView
35+
from .socials import SocialView

app/controllers/socials.py

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
from flask import request
2+
from flask_restful import Resource
3+
from flask_jwt_extended import jwt_required, get_jwt_identity
4+
from sqlalchemy import or_, desc, func
5+
6+
from app.models.user import User, Followers
7+
from app.models.project import Project
8+
from app.models.tags import Tag, TagFollowers
9+
from app.models.project_users import ProjectFollowers
10+
from app.schemas.user import UserSchema
11+
from app.schemas.project import ProjectSchema
12+
from app.schemas.tags import TagSchema
13+
14+
15+
class SocialService:
16+
"""Service class for handling social data operations"""
17+
18+
@staticmethod
19+
def _handle_schema_result(schema_result):
20+
"""Helper method to handle different schema dump return formats"""
21+
if hasattr(schema_result, 'data'):
22+
data = schema_result.data
23+
else:
24+
data = schema_result
25+
26+
if data is None:
27+
data = []
28+
elif not isinstance(data, list):
29+
try:
30+
data = list(data)
31+
except:
32+
data = []
33+
34+
return data
35+
36+
@staticmethod
37+
def get_projects_data(current_user, search=None, filter_type=None, page=1, per_page=10, paginate=True):
38+
"""Get projects data using schema methods for counts"""
39+
40+
# Base query
41+
query = Project.query.filter(
42+
Project.deleted == False,
43+
Project.disabled == False,
44+
Project.admin_disabled == False,
45+
Project.is_public == True
46+
)
47+
48+
if search and search.strip():
49+
search_filter = or_(
50+
Project.name.ilike(f'%{search}%'),
51+
Project.description.ilike(f'%{search}%'),
52+
Project.alias.ilike(f'%{search}%')
53+
)
54+
query = query.filter(search_filter)
55+
56+
if filter_type == 'trending':
57+
query = query.outerjoin(ProjectFollowers).group_by(Project.id).order_by(
58+
desc(func.count(ProjectFollowers.id))
59+
)
60+
elif filter_type == 'recently_updated':
61+
query = query.order_by(desc(Project.updated_at))
62+
elif filter_type == 'newly_added':
63+
query = query.order_by(desc(Project.date_created))
64+
else:
65+
query = query.order_by(desc(Project.date_created))
66+
67+
if paginate:
68+
paginated = query.paginate(page=page, per_page=per_page, error_out=False)
69+
projects = paginated.items
70+
71+
project_schema = ProjectSchema(many=True)
72+
schema_result = project_schema.dump(projects)
73+
projects_data = SocialService._handle_schema_result(schema_result)
74+
75+
return {
76+
'projects': projects_data,
77+
'pagination': {
78+
'total': paginated.total,
79+
'pages': paginated.pages,
80+
'page': paginated.page,
81+
'per_page': paginated.per_page,
82+
'next': paginated.next_num,
83+
'prev': paginated.prev_num
84+
}
85+
}
86+
else:
87+
offset = (page - 1) * per_page
88+
projects = query.offset(offset).limit(per_page).all()
89+
90+
project_schema = ProjectSchema(many=True)
91+
schema_result = project_schema.dump(projects)
92+
projects_data = SocialService._handle_schema_result(schema_result)
93+
94+
return projects_data
95+
96+
@staticmethod
97+
def get_users_data(current_user, search=None, filter_type=None, page=1, per_page=10, paginate=True):
98+
"""Get users data using schema methods for counts"""
99+
100+
query = User.query.filter(
101+
User.disabled == False,
102+
User.admin_disabled == False,
103+
User.is_public == True,
104+
User.verified == True
105+
)
106+
107+
if search and search.strip():
108+
search_filter = or_(
109+
User.name.ilike(f'%{search}%'),
110+
User.username.ilike(f'%{search}%'),
111+
User.biography.ilike(f'%{search}%')
112+
)
113+
query = query.filter(search_filter)
114+
115+
if filter_type == 'trending':
116+
query = query.outerjoin(Followers, Followers.followed_id == User.id).group_by(User.id).order_by(
117+
desc(func.count(Followers.follower_id))
118+
)
119+
elif filter_type == 'recently_updated':
120+
query = query.order_by(desc(User.last_seen))
121+
elif filter_type == 'newly_added':
122+
query = query.order_by(desc(User.date_created))
123+
else:
124+
query = query.order_by(desc(User.date_created))
125+
126+
if paginate:
127+
paginated = query.paginate(page=page, per_page=per_page, error_out=False)
128+
users = paginated.items
129+
130+
user_schema = UserSchema(many=True)
131+
schema_result = user_schema.dump(users)
132+
users_data = SocialService._handle_schema_result(schema_result)
133+
134+
return {
135+
'users': users_data,
136+
'pagination': {
137+
'total': paginated.total,
138+
'pages': paginated.pages,
139+
'page': paginated.page,
140+
'per_page': paginated.per_page,
141+
'next': paginated.next_num,
142+
'prev': paginated.prev_num
143+
}
144+
}
145+
else:
146+
offset = (page - 1) * per_page
147+
users = query.offset(offset).limit(per_page).all()
148+
149+
user_schema = UserSchema(many=True)
150+
schema_result = user_schema.dump(users)
151+
users_data = SocialService._handle_schema_result(schema_result)
152+
153+
return users_data
154+
155+
@staticmethod
156+
def get_tags_data(current_user, search=None, filter_type=None, page=1, per_page=10, paginate=True):
157+
"""Get tags data using schema methods for counts"""
158+
159+
query = Tag.query.filter(Tag.deleted == False)
160+
161+
if search and search.strip():
162+
query = query.filter(Tag.name.ilike(f'%{search}%'))
163+
164+
if filter_type == 'trending':
165+
query = query.outerjoin(TagFollowers).group_by(Tag.id).order_by(
166+
desc(func.count(TagFollowers.id))
167+
)
168+
elif filter_type == 'recently_updated':
169+
query = query.order_by(desc(Tag.updated_at))
170+
elif filter_type == 'newly_added':
171+
query = query.order_by(desc(Tag.date_created))
172+
else:
173+
query = query.order_by(desc(Tag.date_created))
174+
175+
if paginate:
176+
paginated = query.paginate(page=page, per_page=per_page, error_out=False)
177+
tags = paginated.items
178+
179+
tag_schema = TagSchema(many=True)
180+
schema_result = tag_schema.dump(tags)
181+
tags_data = SocialService._handle_schema_result(schema_result)
182+
183+
return {
184+
'tags': tags_data,
185+
'pagination': {
186+
'total': paginated.total,
187+
'pages': paginated.pages,
188+
'page': paginated.page,
189+
'per_page': paginated.per_page,
190+
'next': paginated.next_num,
191+
'prev': paginated.prev_num
192+
}
193+
}
194+
else:
195+
offset = (page - 1) * per_page
196+
tags = query.offset(offset).limit(per_page).all()
197+
198+
tag_schema = TagSchema(many=True)
199+
schema_result = tag_schema.dump(tags)
200+
tags_data = SocialService._handle_schema_result(schema_result)
201+
202+
return tags_data
203+
204+
class SocialView(Resource):
205+
"""
206+
Social View for browsing public projects, users, and tags
207+
208+
GET /social?entity=projects&page=1&per_page=10&search=python&filter=trending
209+
GET /social?entity=users&page=1&per_page=10&search=john&filter=recently_updated
210+
GET /social?entity=tags&page=1&per_page=10&search=machine&filter=newly_added
211+
GET /social (returns all entities combined with distributed per_page)
212+
"""
213+
214+
def __init__(self):
215+
self.social_service = SocialService()
216+
217+
@jwt_required
218+
def get(self):
219+
current_user_id = get_jwt_identity()
220+
current_user = User.get_by_id(current_user_id)
221+
222+
if not current_user:
223+
return dict(status="fail", message="User not found"), 404
224+
225+
entity = request.args.get('entity', '').lower()
226+
search = request.args.get('search', '').strip() or None
227+
filter_type = request.args.get('filter', '').lower() or None
228+
229+
page = request.args.get('page', 1, type=int)
230+
per_page = request.args.get('per_page', 10, type=int)
231+
232+
# Validate pagination parameters
233+
per_page = max(1, min(per_page, 100))
234+
page = max(1, page)
235+
236+
valid_entities = ['projects', 'users', 'tags']
237+
if entity and entity not in valid_entities:
238+
return dict(
239+
status="fail",
240+
message=f"Invalid entity. Must be one of: {', '.join(valid_entities)}"
241+
), 400
242+
243+
valid_filters = ['trending', 'recently_updated', 'newly_added']
244+
if filter_type and filter_type not in valid_filters:
245+
return dict(
246+
status="fail",
247+
message=f"Invalid filter. Must be one of: {', '.join(valid_filters)}"
248+
), 400
249+
250+
try:
251+
if entity:
252+
# Single entity request with pagination
253+
if entity == 'projects':
254+
result = self.social_service.get_projects_data(
255+
current_user, search, filter_type, page, per_page, paginate=True
256+
)
257+
elif entity == 'users':
258+
result = self.social_service.get_users_data(
259+
current_user, search, filter_type, page, per_page, paginate=True
260+
)
261+
elif entity == 'tags':
262+
result = self.social_service.get_tags_data(
263+
current_user, search, filter_type, page, per_page, paginate=True
264+
)
265+
266+
return dict(status='success', data=result), 200
267+
else:
268+
# All entities request - distribute per_page across entities
269+
items_per_entity = max(1, per_page // 3)
270+
271+
# Get data for all entities (lists only, no pagination metadata)
272+
projects_data = self.social_service.get_projects_data(
273+
current_user, search, filter_type,
274+
page=page, per_page=items_per_entity, paginate=False
275+
)
276+
users_data = self.social_service.get_users_data(
277+
current_user, search, filter_type,
278+
page=page, per_page=items_per_entity, paginate=False
279+
)
280+
tags_data = self.social_service.get_tags_data(
281+
current_user, search, filter_type,
282+
page=page, per_page=items_per_entity, paginate=False
283+
)
284+
285+
# Limit each entity to the calculated items_per_entity
286+
projects_data = projects_data[:items_per_entity]
287+
users_data = users_data[:items_per_entity]
288+
tags_data = tags_data[:items_per_entity]
289+
290+
# Calculate total items across all entities
291+
total_items = len(projects_data) + len(users_data) + len(tags_data)
292+
293+
result = {
294+
'projects': projects_data,
295+
'users': users_data,
296+
'tags': tags_data,
297+
'pagination': {
298+
'current_page': page,
299+
'per_page': per_page,
300+
'items_per_entity': items_per_entity,
301+
'total_items': total_items,
302+
'total_entities': 3
303+
}
304+
}
305+
306+
return dict(status='success', data=result), 200
307+
308+
except Exception as e:
309+
return {"status":"fail", "message": f"An error occurred while fetching social data: {str(e)}"}, 500

app/models/project.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ class Project(ModelMixin):
2424
organisation = db.Column(db.String)
2525
project_type = db.Column(db.String)
2626
date_created = db.Column(db.DateTime, default=db.func.current_timestamp())
27+
updated_at = db.Column(db.DateTime, default=db.func.current_timestamp(),
28+
onupdate=db.func.current_timestamp())
2729
users = relationship('ProjectUser', back_populates='other_project')
2830
followers = relationship('ProjectFollowers', back_populates='project')
2931
is_public = db.Column(db.Boolean, default=True)

0 commit comments

Comments
 (0)