angeetoile commited on
Commit
3adf1fb
·
1 Parent(s): e2800b3

feat(opportunities): add verified opportunities API

Browse files
alembic/versions/20260803_0002_create_opportunities.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Create sources and opportunities tables.
2
+
3
+ Revision ID: 20260803_0002
4
+ Revises: 20260803_0001
5
+ """
6
+
7
+ from collections.abc import Sequence
8
+
9
+ from alembic import op
10
+ import sqlalchemy as sa
11
+ from sqlalchemy.dialects import postgresql
12
+
13
+
14
+ revision: str = "20260803_0002"
15
+ down_revision: str | None = "20260803_0001"
16
+ branch_labels: str | Sequence[str] | None = None
17
+ depends_on: str | Sequence[str] | None = None
18
+
19
+
20
+ def upgrade() -> None:
21
+ op.create_table(
22
+ "sources",
23
+ sa.Column(
24
+ "id",
25
+ postgresql.UUID(as_uuid=True),
26
+ nullable=False,
27
+ ),
28
+ sa.Column(
29
+ "name",
30
+ sa.String(length=180),
31
+ nullable=False,
32
+ ),
33
+ sa.Column(
34
+ "slug",
35
+ sa.String(length=180),
36
+ nullable=False,
37
+ ),
38
+ sa.Column(
39
+ "organization_type",
40
+ sa.String(length=50),
41
+ server_default="organization",
42
+ nullable=False,
43
+ ),
44
+ sa.Column(
45
+ "base_url",
46
+ sa.Text(),
47
+ nullable=False,
48
+ ),
49
+ sa.Column(
50
+ "opportunities_url",
51
+ sa.Text(),
52
+ nullable=True,
53
+ ),
54
+ sa.Column(
55
+ "feed_url",
56
+ sa.Text(),
57
+ nullable=True,
58
+ ),
59
+ sa.Column(
60
+ "logo_url",
61
+ sa.Text(),
62
+ nullable=True,
63
+ ),
64
+ sa.Column(
65
+ "country_code",
66
+ sa.String(length=2),
67
+ nullable=True,
68
+ ),
69
+ sa.Column(
70
+ "source_tier",
71
+ sa.String(length=1),
72
+ server_default="B",
73
+ nullable=False,
74
+ ),
75
+ sa.Column(
76
+ "is_official",
77
+ sa.Boolean(),
78
+ server_default=sa.true(),
79
+ nullable=False,
80
+ ),
81
+ sa.Column(
82
+ "is_active",
83
+ sa.Boolean(),
84
+ server_default=sa.true(),
85
+ nullable=False,
86
+ ),
87
+ sa.Column(
88
+ "last_checked_at",
89
+ sa.DateTime(timezone=True),
90
+ nullable=True,
91
+ ),
92
+ sa.Column(
93
+ "created_at",
94
+ sa.DateTime(timezone=True),
95
+ server_default=sa.func.now(),
96
+ nullable=False,
97
+ ),
98
+ sa.Column(
99
+ "updated_at",
100
+ sa.DateTime(timezone=True),
101
+ server_default=sa.func.now(),
102
+ nullable=False,
103
+ ),
104
+ sa.PrimaryKeyConstraint("id"),
105
+ sa.UniqueConstraint(
106
+ "name",
107
+ name="uq_sources_name",
108
+ ),
109
+ sa.UniqueConstraint(
110
+ "slug",
111
+ name="uq_sources_slug",
112
+ ),
113
+ )
114
+
115
+ op.create_index(
116
+ "ix_sources_slug",
117
+ "sources",
118
+ ["slug"],
119
+ unique=False,
120
+ )
121
+
122
+ op.create_index(
123
+ "ix_sources_active_official",
124
+ "sources",
125
+ ["is_active", "is_official"],
126
+ unique=False,
127
+ )
128
+
129
+ op.create_table(
130
+ "opportunities",
131
+ sa.Column(
132
+ "id",
133
+ postgresql.UUID(as_uuid=True),
134
+ nullable=False,
135
+ ),
136
+ sa.Column(
137
+ "source_id",
138
+ postgresql.UUID(as_uuid=True),
139
+ nullable=False,
140
+ ),
141
+ sa.Column(
142
+ "title",
143
+ sa.String(length=250),
144
+ nullable=False,
145
+ ),
146
+ sa.Column(
147
+ "slug",
148
+ sa.String(length=280),
149
+ nullable=False,
150
+ ),
151
+ sa.Column(
152
+ "summary",
153
+ sa.String(length=500),
154
+ nullable=False,
155
+ ),
156
+ sa.Column(
157
+ "description",
158
+ sa.Text(),
159
+ nullable=True,
160
+ ),
161
+ sa.Column(
162
+ "category",
163
+ sa.String(length=50),
164
+ nullable=False,
165
+ ),
166
+ sa.Column(
167
+ "organization_name",
168
+ sa.String(length=180),
169
+ nullable=False,
170
+ ),
171
+ sa.Column(
172
+ "country_code",
173
+ sa.String(length=2),
174
+ nullable=True,
175
+ ),
176
+ sa.Column(
177
+ "location",
178
+ sa.String(length=180),
179
+ nullable=True,
180
+ ),
181
+ sa.Column(
182
+ "target_countries",
183
+ postgresql.JSONB(),
184
+ server_default=sa.text("'[]'::jsonb"),
185
+ nullable=False,
186
+ ),
187
+ sa.Column(
188
+ "study_levels",
189
+ postgresql.JSONB(),
190
+ server_default=sa.text("'[]'::jsonb"),
191
+ nullable=False,
192
+ ),
193
+ sa.Column(
194
+ "eligibility",
195
+ postgresql.JSONB(),
196
+ server_default=sa.text("'{}'::jsonb"),
197
+ nullable=False,
198
+ ),
199
+ sa.Column(
200
+ "language",
201
+ sa.String(length=10),
202
+ nullable=True,
203
+ ),
204
+ sa.Column(
205
+ "publication_date",
206
+ sa.Date(),
207
+ nullable=True,
208
+ ),
209
+ sa.Column(
210
+ "deadline",
211
+ sa.Date(),
212
+ nullable=True,
213
+ ),
214
+ sa.Column(
215
+ "official_url",
216
+ sa.Text(),
217
+ nullable=False,
218
+ ),
219
+ sa.Column(
220
+ "application_url",
221
+ sa.Text(),
222
+ nullable=True,
223
+ ),
224
+ sa.Column(
225
+ "image_url",
226
+ sa.Text(),
227
+ nullable=True,
228
+ ),
229
+ sa.Column(
230
+ "status",
231
+ sa.String(length=30),
232
+ server_default="draft",
233
+ nullable=False,
234
+ ),
235
+ sa.Column(
236
+ "is_featured",
237
+ sa.Boolean(),
238
+ server_default=sa.false(),
239
+ nullable=False,
240
+ ),
241
+ sa.Column(
242
+ "is_source_verified",
243
+ sa.Boolean(),
244
+ server_default=sa.false(),
245
+ nullable=False,
246
+ ),
247
+ sa.Column(
248
+ "verified_at",
249
+ sa.DateTime(timezone=True),
250
+ nullable=True,
251
+ ),
252
+ sa.Column(
253
+ "collected_at",
254
+ sa.DateTime(timezone=True),
255
+ server_default=sa.func.now(),
256
+ nullable=False,
257
+ ),
258
+ sa.Column(
259
+ "created_at",
260
+ sa.DateTime(timezone=True),
261
+ server_default=sa.func.now(),
262
+ nullable=False,
263
+ ),
264
+ sa.Column(
265
+ "updated_at",
266
+ sa.DateTime(timezone=True),
267
+ server_default=sa.func.now(),
268
+ nullable=False,
269
+ ),
270
+ sa.ForeignKeyConstraint(
271
+ ["source_id"],
272
+ ["sources.id"],
273
+ name="fk_opportunities_source_id",
274
+ ondelete="RESTRICT",
275
+ ),
276
+ sa.PrimaryKeyConstraint("id"),
277
+ sa.UniqueConstraint(
278
+ "slug",
279
+ name="uq_opportunities_slug",
280
+ ),
281
+ sa.UniqueConstraint(
282
+ "official_url",
283
+ name="uq_opportunities_official_url",
284
+ ),
285
+ )
286
+
287
+ op.create_index(
288
+ "ix_opportunities_slug",
289
+ "opportunities",
290
+ ["slug"],
291
+ unique=False,
292
+ )
293
+
294
+ op.create_index(
295
+ "ix_opportunities_category_status",
296
+ "opportunities",
297
+ ["category", "status"],
298
+ unique=False,
299
+ )
300
+
301
+ op.create_index(
302
+ "ix_opportunities_deadline",
303
+ "opportunities",
304
+ ["deadline"],
305
+ unique=False,
306
+ )
307
+
308
+ op.create_index(
309
+ "ix_opportunities_featured",
310
+ "opportunities",
311
+ ["is_featured"],
312
+ unique=False,
313
+ )
314
+
315
+
316
+ def downgrade() -> None:
317
+ op.drop_index(
318
+ "ix_opportunities_featured",
319
+ table_name="opportunities",
320
+ )
321
+ op.drop_index(
322
+ "ix_opportunities_deadline",
323
+ table_name="opportunities",
324
+ )
325
+ op.drop_index(
326
+ "ix_opportunities_category_status",
327
+ table_name="opportunities",
328
+ )
329
+ op.drop_index(
330
+ "ix_opportunities_slug",
331
+ table_name="opportunities",
332
+ )
333
+ op.drop_table("opportunities")
334
+
335
+ op.drop_index(
336
+ "ix_sources_active_official",
337
+ table_name="sources",
338
+ )
339
+ op.drop_index(
340
+ "ix_sources_slug",
341
+ table_name="sources",
342
+ )
343
+ op.drop_table("sources")
app/api/v1/endpoints/opportunities.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Annotated
2
+
3
+ from fastapi import (
4
+ APIRouter,
5
+ Depends,
6
+ HTTPException,
7
+ Query,
8
+ status,
9
+ )
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from app.db.session import get_database_session
13
+ from app.schemas.opportunity import (
14
+ OpportunityCategory,
15
+ OpportunityCategoryResponse,
16
+ OpportunityDetail,
17
+ OpportunityListResponse,
18
+ OpportunitySort,
19
+ )
20
+ from app.services.opportunity import (
21
+ OpportunityNotFoundError,
22
+ OpportunityService,
23
+ )
24
+
25
+
26
+ router = APIRouter()
27
+
28
+
29
+ @router.get(
30
+ "",
31
+ response_model=OpportunityListResponse,
32
+ summary="Lister les opportunités vérifiées",
33
+ )
34
+ async def list_opportunities(
35
+ session: Annotated[
36
+ AsyncSession,
37
+ Depends(get_database_session),
38
+ ],
39
+ page: Annotated[int, Query(ge=1)] = 1,
40
+ page_size: Annotated[
41
+ int,
42
+ Query(ge=1, le=100),
43
+ ] = 12,
44
+ search: Annotated[
45
+ str | None,
46
+ Query(min_length=2, max_length=100),
47
+ ] = None,
48
+ category: OpportunityCategory | None = None,
49
+ country: Annotated[
50
+ str | None,
51
+ Query(min_length=2, max_length=2),
52
+ ] = None,
53
+ featured: bool | None = None,
54
+ sort: OpportunitySort = OpportunitySort.RECENT,
55
+ ) -> OpportunityListResponse:
56
+ service = OpportunityService(session)
57
+
58
+ return await service.list_opportunities(
59
+ page=page,
60
+ page_size=page_size,
61
+ search=search,
62
+ category=category,
63
+ country=country,
64
+ featured=featured,
65
+ sort=sort,
66
+ )
67
+
68
+
69
+ @router.get(
70
+ "/categories",
71
+ response_model=list[OpportunityCategoryResponse],
72
+ summary="Lister les catégories d’opportunités",
73
+ )
74
+ async def list_opportunity_categories(
75
+ session: Annotated[
76
+ AsyncSession,
77
+ Depends(get_database_session),
78
+ ],
79
+ ) -> list[OpportunityCategoryResponse]:
80
+ service = OpportunityService(session)
81
+ return service.list_categories()
82
+
83
+
84
+ @router.get(
85
+ "/{slug}",
86
+ response_model=OpportunityDetail,
87
+ summary="Consulter une opportunité vérifiée",
88
+ )
89
+ async def get_opportunity(
90
+ slug: str,
91
+ session: Annotated[
92
+ AsyncSession,
93
+ Depends(get_database_session),
94
+ ],
95
+ ) -> OpportunityDetail:
96
+ service = OpportunityService(session)
97
+
98
+ try:
99
+ opportunity = await service.get_by_slug(slug)
100
+
101
+ except OpportunityNotFoundError as error:
102
+ raise HTTPException(
103
+ status_code=status.HTTP_404_NOT_FOUND,
104
+ detail=(
105
+ "Cette opportunité est introuvable, "
106
+ "expirée ou non publiée."
107
+ ),
108
+ ) from error
109
+
110
+ return OpportunityDetail.model_validate(opportunity)
app/api/v1/router.py CHANGED
@@ -1,11 +1,16 @@
1
  from fastapi import APIRouter
2
 
3
- from app.api.v1.endpoints import health
 
4
 
5
  api_router = APIRouter()
6
 
7
  api_router.include_router(
8
  health.router,
9
- prefix="/health",
10
- tags=["Health"],
 
 
 
 
11
  )
 
1
  from fastapi import APIRouter
2
 
3
+ from app.api.v1.endpoints import health, opportunities
4
+
5
 
6
  api_router = APIRouter()
7
 
8
  api_router.include_router(
9
  health.router,
10
+ )
11
+
12
+ api_router.include_router(
13
+ opportunities.router,
14
+ prefix="/opportunities",
15
+ tags=["Opportunities"],
16
  )
app/models/__init__.py CHANGED
@@ -1,3 +1,9 @@
 
 
1
  from app.models.user import User
2
 
3
- __all__ = ["User"]
 
 
 
 
 
1
+ from app.models.opportunity import Opportunity
2
+ from app.models.source import Source
3
  from app.models.user import User
4
 
5
+ __all__ = [
6
+ "Opportunity",
7
+ "Source",
8
+ "User",
9
+ ]
app/models/opportunity.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import date, datetime
3
+
4
+ from sqlalchemy import (
5
+ Boolean,
6
+ Date,
7
+ DateTime,
8
+ ForeignKey,
9
+ Index,
10
+ String,
11
+ Text,
12
+ func,
13
+ text,
14
+ )
15
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
16
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
17
+
18
+ from app.db.base import Base
19
+
20
+
21
+ class Opportunity(Base):
22
+ __tablename__ = "opportunities"
23
+
24
+ id: Mapped[uuid.UUID] = mapped_column(
25
+ UUID(as_uuid=True),
26
+ primary_key=True,
27
+ default=uuid.uuid4,
28
+ )
29
+
30
+ source_id: Mapped[uuid.UUID] = mapped_column(
31
+ UUID(as_uuid=True),
32
+ ForeignKey(
33
+ "sources.id",
34
+ ondelete="RESTRICT",
35
+ ),
36
+ nullable=False,
37
+ )
38
+
39
+ title: Mapped[str] = mapped_column(
40
+ String(250),
41
+ nullable=False,
42
+ )
43
+
44
+ slug: Mapped[str] = mapped_column(
45
+ String(280),
46
+ nullable=False,
47
+ unique=True,
48
+ )
49
+
50
+ summary: Mapped[str] = mapped_column(
51
+ String(500),
52
+ nullable=False,
53
+ )
54
+
55
+ description: Mapped[str | None] = mapped_column(
56
+ Text,
57
+ nullable=True,
58
+ )
59
+
60
+ category: Mapped[str] = mapped_column(
61
+ String(50),
62
+ nullable=False,
63
+ )
64
+
65
+ organization_name: Mapped[str] = mapped_column(
66
+ String(180),
67
+ nullable=False,
68
+ )
69
+
70
+ country_code: Mapped[str | None] = mapped_column(
71
+ String(2),
72
+ nullable=True,
73
+ )
74
+
75
+ location: Mapped[str | None] = mapped_column(
76
+ String(180),
77
+ nullable=True,
78
+ )
79
+
80
+ target_countries: Mapped[list[str]] = mapped_column(
81
+ JSONB,
82
+ nullable=False,
83
+ default=list,
84
+ server_default=text("'[]'::jsonb"),
85
+ )
86
+
87
+ study_levels: Mapped[list[str]] = mapped_column(
88
+ JSONB,
89
+ nullable=False,
90
+ default=list,
91
+ server_default=text("'[]'::jsonb"),
92
+ )
93
+
94
+ eligibility: Mapped[dict] = mapped_column(
95
+ JSONB,
96
+ nullable=False,
97
+ default=dict,
98
+ server_default=text("'{}'::jsonb"),
99
+ )
100
+
101
+ language: Mapped[str | None] = mapped_column(
102
+ String(10),
103
+ nullable=True,
104
+ )
105
+
106
+ publication_date: Mapped[date | None] = mapped_column(
107
+ Date,
108
+ nullable=True,
109
+ )
110
+
111
+ deadline: Mapped[date | None] = mapped_column(
112
+ Date,
113
+ nullable=True,
114
+ )
115
+
116
+ official_url: Mapped[str] = mapped_column(
117
+ Text,
118
+ nullable=False,
119
+ unique=True,
120
+ )
121
+
122
+ application_url: Mapped[str | None] = mapped_column(
123
+ Text,
124
+ nullable=True,
125
+ )
126
+
127
+ image_url: Mapped[str | None] = mapped_column(
128
+ Text,
129
+ nullable=True,
130
+ )
131
+
132
+ status: Mapped[str] = mapped_column(
133
+ String(30),
134
+ nullable=False,
135
+ default="draft",
136
+ server_default="draft",
137
+ )
138
+
139
+ is_featured: Mapped[bool] = mapped_column(
140
+ Boolean,
141
+ nullable=False,
142
+ default=False,
143
+ server_default="false",
144
+ )
145
+
146
+ is_source_verified: Mapped[bool] = mapped_column(
147
+ Boolean,
148
+ nullable=False,
149
+ default=False,
150
+ server_default="false",
151
+ )
152
+
153
+ verified_at: Mapped[datetime | None] = mapped_column(
154
+ DateTime(timezone=True),
155
+ nullable=True,
156
+ )
157
+
158
+ collected_at: Mapped[datetime] = mapped_column(
159
+ DateTime(timezone=True),
160
+ nullable=False,
161
+ server_default=func.now(),
162
+ )
163
+
164
+ created_at: Mapped[datetime] = mapped_column(
165
+ DateTime(timezone=True),
166
+ nullable=False,
167
+ server_default=func.now(),
168
+ )
169
+
170
+ updated_at: Mapped[datetime] = mapped_column(
171
+ DateTime(timezone=True),
172
+ nullable=False,
173
+ server_default=func.now(),
174
+ onupdate=func.now(),
175
+ )
176
+
177
+ source = relationship(
178
+ "Source",
179
+ back_populates="opportunities",
180
+ lazy="joined",
181
+ )
182
+
183
+ __table_args__ = (
184
+ Index("ix_opportunities_slug", "slug"),
185
+ Index(
186
+ "ix_opportunities_category_status",
187
+ "category",
188
+ "status",
189
+ ),
190
+ Index(
191
+ "ix_opportunities_deadline",
192
+ "deadline",
193
+ ),
194
+ Index(
195
+ "ix_opportunities_featured",
196
+ "is_featured",
197
+ ),
198
+ )
app/models/source.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import datetime
3
+
4
+ from sqlalchemy import (
5
+ Boolean,
6
+ DateTime,
7
+ Index,
8
+ String,
9
+ Text,
10
+ func,
11
+ )
12
+ from sqlalchemy.dialects.postgresql import UUID
13
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
14
+
15
+ from app.db.base import Base
16
+
17
+
18
+ class Source(Base):
19
+ __tablename__ = "sources"
20
+
21
+ id: Mapped[uuid.UUID] = mapped_column(
22
+ UUID(as_uuid=True),
23
+ primary_key=True,
24
+ default=uuid.uuid4,
25
+ )
26
+
27
+ name: Mapped[str] = mapped_column(
28
+ String(180),
29
+ nullable=False,
30
+ unique=True,
31
+ )
32
+
33
+ slug: Mapped[str] = mapped_column(
34
+ String(180),
35
+ nullable=False,
36
+ unique=True,
37
+ )
38
+
39
+ organization_type: Mapped[str] = mapped_column(
40
+ String(50),
41
+ nullable=False,
42
+ default="organization",
43
+ server_default="organization",
44
+ )
45
+
46
+ base_url: Mapped[str] = mapped_column(
47
+ Text,
48
+ nullable=False,
49
+ )
50
+
51
+ opportunities_url: Mapped[str | None] = mapped_column(
52
+ Text,
53
+ nullable=True,
54
+ )
55
+
56
+ feed_url: Mapped[str | None] = mapped_column(
57
+ Text,
58
+ nullable=True,
59
+ )
60
+
61
+ logo_url: Mapped[str | None] = mapped_column(
62
+ Text,
63
+ nullable=True,
64
+ )
65
+
66
+ country_code: Mapped[str | None] = mapped_column(
67
+ String(2),
68
+ nullable=True,
69
+ )
70
+
71
+ source_tier: Mapped[str] = mapped_column(
72
+ String(1),
73
+ nullable=False,
74
+ default="B",
75
+ server_default="B",
76
+ )
77
+
78
+ is_official: Mapped[bool] = mapped_column(
79
+ Boolean,
80
+ nullable=False,
81
+ default=True,
82
+ server_default="true",
83
+ )
84
+
85
+ is_active: Mapped[bool] = mapped_column(
86
+ Boolean,
87
+ nullable=False,
88
+ default=True,
89
+ server_default="true",
90
+ )
91
+
92
+ last_checked_at: Mapped[datetime | None] = mapped_column(
93
+ DateTime(timezone=True),
94
+ nullable=True,
95
+ )
96
+
97
+ created_at: Mapped[datetime] = mapped_column(
98
+ DateTime(timezone=True),
99
+ nullable=False,
100
+ server_default=func.now(),
101
+ )
102
+
103
+ updated_at: Mapped[datetime] = mapped_column(
104
+ DateTime(timezone=True),
105
+ nullable=False,
106
+ server_default=func.now(),
107
+ onupdate=func.now(),
108
+ )
109
+
110
+ opportunities = relationship(
111
+ "Opportunity",
112
+ back_populates="source",
113
+ lazy="selectin",
114
+ )
115
+
116
+ __table_args__ = (
117
+ Index("ix_sources_slug", "slug"),
118
+ Index(
119
+ "ix_sources_active_official",
120
+ "is_active",
121
+ "is_official",
122
+ ),
123
+ )
app/repositories/opportunity.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date
2
+
3
+ from sqlalchemy import func, or_, select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy.orm import selectinload
6
+
7
+ from app.models.opportunity import Opportunity
8
+ from app.schemas.opportunity import OpportunitySort
9
+
10
+
11
+ class OpportunityRepository:
12
+ def __init__(self, session: AsyncSession) -> None:
13
+ self.session = session
14
+
15
+ def _public_query(self):
16
+ return (
17
+ select(Opportunity)
18
+ .options(selectinload(Opportunity.source))
19
+ .where(
20
+ Opportunity.status == "published",
21
+ Opportunity.is_source_verified.is_(True),
22
+ or_(
23
+ Opportunity.deadline.is_(None),
24
+ Opportunity.deadline >= date.today(),
25
+ ),
26
+ )
27
+ )
28
+
29
+ async def list_public(
30
+ self,
31
+ *,
32
+ page: int,
33
+ page_size: int,
34
+ search: str | None = None,
35
+ category: str | None = None,
36
+ country: str | None = None,
37
+ featured: bool | None = None,
38
+ sort: OpportunitySort = OpportunitySort.RECENT,
39
+ ) -> tuple[list[Opportunity], int]:
40
+ query = self._public_query()
41
+
42
+ if search:
43
+ pattern = f"%{search.strip()}%"
44
+
45
+ query = query.where(
46
+ or_(
47
+ Opportunity.title.ilike(pattern),
48
+ Opportunity.summary.ilike(pattern),
49
+ Opportunity.organization_name.ilike(pattern),
50
+ )
51
+ )
52
+
53
+ if category:
54
+ query = query.where(
55
+ Opportunity.category == category
56
+ )
57
+
58
+ if country:
59
+ normalized_country = country.strip().upper()
60
+
61
+ query = query.where(
62
+ or_(
63
+ Opportunity.country_code
64
+ == normalized_country,
65
+ Opportunity.target_countries.contains(
66
+ [normalized_country]
67
+ ),
68
+ )
69
+ )
70
+
71
+ if featured is not None:
72
+ query = query.where(
73
+ Opportunity.is_featured.is_(featured)
74
+ )
75
+
76
+ count_query = select(
77
+ func.count()
78
+ ).select_from(query.order_by(None).subquery())
79
+
80
+ total_result = await self.session.execute(
81
+ count_query
82
+ )
83
+ total_items = total_result.scalar_one()
84
+
85
+ if sort == OpportunitySort.DEADLINE:
86
+ query = query.order_by(
87
+ Opportunity.deadline.asc().nullslast(),
88
+ Opportunity.created_at.desc(),
89
+ )
90
+
91
+ elif sort == OpportunitySort.FEATURED:
92
+ query = query.order_by(
93
+ Opportunity.is_featured.desc(),
94
+ Opportunity.deadline.asc().nullslast(),
95
+ Opportunity.created_at.desc(),
96
+ )
97
+
98
+ else:
99
+ query = query.order_by(
100
+ Opportunity.created_at.desc()
101
+ )
102
+
103
+ query = query.offset(
104
+ (page - 1) * page_size
105
+ ).limit(page_size)
106
+
107
+ result = await self.session.execute(query)
108
+
109
+ return list(result.scalars().all()), total_items
110
+
111
+ async def get_public_by_slug(
112
+ self,
113
+ slug: str,
114
+ ) -> Opportunity | None:
115
+ query = self._public_query().where(
116
+ Opportunity.slug == slug
117
+ )
118
+
119
+ result = await self.session.execute(query)
120
+ return result.scalar_one_or_none()
app/schemas/opportunity.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import date, datetime
3
+ from enum import StrEnum
4
+ from typing import Any
5
+
6
+ from pydantic import (
7
+ BaseModel,
8
+ ConfigDict,
9
+ Field,
10
+ HttpUrl,
11
+ )
12
+
13
+
14
+ class OpportunityCategory(StrEnum):
15
+ SCHOLARSHIP = "scholarship"
16
+ INTERNSHIP = "internship"
17
+ FELLOWSHIP = "fellowship"
18
+ TRAINING = "training"
19
+ COMPETITION = "competition"
20
+ HACKATHON = "hackathon"
21
+ GRANT = "grant"
22
+ JOB = "job"
23
+ OTHER = "other"
24
+
25
+
26
+ class OpportunityStatus(StrEnum):
27
+ DRAFT = "draft"
28
+ PUBLISHED = "published"
29
+ EXPIRED = "expired"
30
+ ARCHIVED = "archived"
31
+ REJECTED = "rejected"
32
+ class OpportunitySort(StrEnum):
33
+ RECENT = "recent"
34
+ DEADLINE = "deadline"
35
+ FEATURED = "featured"
36
+
37
+ class SourceRead(BaseModel):
38
+ model_config = ConfigDict(from_attributes=True)
39
+
40
+ id: uuid.UUID
41
+ name: str
42
+ slug: str
43
+ organization_type: str
44
+ base_url: HttpUrl
45
+ logo_url: HttpUrl | None
46
+ country_code: str | None
47
+ source_tier: str
48
+ is_official: bool
49
+ last_checked_at: datetime | None
50
+
51
+
52
+ class OpportunityListItem(BaseModel):
53
+ model_config = ConfigDict(from_attributes=True)
54
+
55
+ id: uuid.UUID
56
+ title: str
57
+ slug: str
58
+ summary: str
59
+ category: OpportunityCategory
60
+ organization_name: str
61
+ country_code: str | None
62
+ location: str | None
63
+ target_countries: list[str]
64
+ deadline: date | None
65
+ official_url: HttpUrl
66
+ image_url: HttpUrl | None
67
+ is_featured: bool
68
+ is_source_verified: bool
69
+ verified_at: datetime | None
70
+ source: SourceRead
71
+
72
+
73
+ class OpportunityDetail(OpportunityListItem):
74
+ description: str | None
75
+ study_levels: list[str]
76
+ eligibility: dict[str, Any]
77
+ language: str | None
78
+ publication_date: date | None
79
+ application_url: HttpUrl | None
80
+ status: OpportunityStatus
81
+ collected_at: datetime
82
+ created_at: datetime
83
+ updated_at: datetime
84
+
85
+
86
+ class OpportunityPagination(BaseModel):
87
+ page: int = Field(ge=1)
88
+ page_size: int = Field(ge=1, le=100)
89
+ total_items: int = Field(ge=0)
90
+ total_pages: int = Field(ge=0)
91
+ has_next: bool
92
+ has_previous: bool
93
+
94
+
95
+ class OpportunityListResponse(BaseModel):
96
+ items: list[OpportunityListItem]
97
+ pagination: OpportunityPagination
98
+
99
+
100
+ class OpportunityCategoryResponse(BaseModel):
101
+ value: OpportunityCategory
102
+ label_fr: str
103
+ label_en: str
app/services/opportunity.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from math import ceil
2
+
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+
5
+ from app.models.opportunity import Opportunity
6
+ from app.repositories.opportunity import (
7
+ OpportunityRepository,
8
+ )
9
+ from app.schemas.opportunity import (
10
+ OpportunityCategory,
11
+ OpportunityCategoryResponse,
12
+ OpportunityListResponse,
13
+ OpportunityPagination,
14
+ OpportunitySort,
15
+ )
16
+
17
+
18
+ class OpportunityNotFoundError(Exception):
19
+ pass
20
+
21
+
22
+ class OpportunityService:
23
+ def __init__(self, session: AsyncSession) -> None:
24
+ self.repository = OpportunityRepository(session)
25
+
26
+ async def list_opportunities(
27
+ self,
28
+ *,
29
+ page: int,
30
+ page_size: int,
31
+ search: str | None,
32
+ category: OpportunityCategory | None,
33
+ country: str | None,
34
+ featured: bool | None,
35
+ sort: OpportunitySort,
36
+ ) -> OpportunityListResponse:
37
+ opportunities, total_items = (
38
+ await self.repository.list_public(
39
+ page=page,
40
+ page_size=page_size,
41
+ search=search,
42
+ category=(
43
+ category.value if category else None
44
+ ),
45
+ country=country,
46
+ featured=featured,
47
+ sort=sort,
48
+ )
49
+ )
50
+
51
+ total_pages = (
52
+ ceil(total_items / page_size)
53
+ if total_items
54
+ else 0
55
+ )
56
+
57
+ return OpportunityListResponse.model_validate(
58
+ {
59
+ "items": opportunities,
60
+ "pagination": OpportunityPagination(
61
+ page=page,
62
+ page_size=page_size,
63
+ total_items=total_items,
64
+ total_pages=total_pages,
65
+ has_next=page < total_pages,
66
+ has_previous=page > 1,
67
+ ),
68
+ }
69
+ )
70
+
71
+ async def get_by_slug(
72
+ self,
73
+ slug: str,
74
+ ) -> Opportunity:
75
+ opportunity = (
76
+ await self.repository.get_public_by_slug(slug)
77
+ )
78
+
79
+ if opportunity is None:
80
+ raise OpportunityNotFoundError
81
+
82
+ return opportunity
83
+
84
+ def list_categories(
85
+ self,
86
+ ) -> list[OpportunityCategoryResponse]:
87
+ labels = {
88
+ OpportunityCategory.SCHOLARSHIP: (
89
+ "Bourses",
90
+ "Scholarships",
91
+ ),
92
+ OpportunityCategory.INTERNSHIP: (
93
+ "Stages",
94
+ "Internships",
95
+ ),
96
+ OpportunityCategory.FELLOWSHIP: (
97
+ "Programmes de fellowship",
98
+ "Fellowships",
99
+ ),
100
+ OpportunityCategory.TRAINING: (
101
+ "Formations",
102
+ "Training",
103
+ ),
104
+ OpportunityCategory.COMPETITION: (
105
+ "Concours",
106
+ "Competitions",
107
+ ),
108
+ OpportunityCategory.HACKATHON: (
109
+ "Hackathons",
110
+ "Hackathons",
111
+ ),
112
+ OpportunityCategory.GRANT: (
113
+ "Financements",
114
+ "Grants",
115
+ ),
116
+ OpportunityCategory.JOB: (
117
+ "Emplois",
118
+ "Jobs",
119
+ ),
120
+ OpportunityCategory.OTHER: (
121
+ "Autres",
122
+ "Other",
123
+ ),
124
+ }
125
+
126
+ return [
127
+ OpportunityCategoryResponse(
128
+ value=category,
129
+ label_fr=labels[category][0],
130
+ label_en=labels[category][1],
131
+ )
132
+ for category in OpportunityCategory
133
+ ]