Skip to content

Commit 1ee995b

Browse files
authored
Added search feature (#98)
* Added search feature
1 parent 99e69a8 commit 1ee995b

File tree

15 files changed

+460
-15
lines changed

15 files changed

+460
-15
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,9 @@ dmypy.json
138138
# Pyre type checker
139139
.pyre/
140140

141+
142+
# VScode
143+
.vscode/
144+
app/.vscode/
145+
141146
app/routers/stam

app/config.py.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import os
22

33
from fastapi_mail import ConnectionConfig
4-
54
# flake8: noqa
65

76
# general
87
DOMAIN = 'Our-Domain'
98

109
# DATABASE
1110
DEVELOPMENT_DATABASE_STRING = "sqlite:///./dev.db"
11+
# Set the following True if working on PSQL environment or set False otherwise
12+
PSQL_ENVIRONMENT = False
1213

1314
# MEDIA
1415
MEDIA_DIRECTORY = 'media'

app/database/database.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,20 @@
66

77
from app import config
88

9+
910
SQLALCHEMY_DATABASE_URL = os.getenv(
1011
"DATABASE_CONNECTION_STRING", config.DEVELOPMENT_DATABASE_STRING)
1112

12-
engine = create_engine(
13-
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
14-
)
13+
14+
def create_env_engine(psql_environment, sqlalchemy_database_url):
15+
if not psql_environment:
16+
return create_engine(
17+
sqlalchemy_database_url, connect_args={"check_same_thread": False})
18+
19+
return create_engine(sqlalchemy_database_url)
20+
21+
22+
engine = create_env_engine(config.PSQL_ENVIRONMENT, SQLALCHEMY_DATABASE_URL)
1523
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
1624

1725
Base = declarative_base()

app/database/models.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from datetime import datetime
22

3-
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
4-
from sqlalchemy.orm import relationship
5-
3+
from app.config import PSQL_ENVIRONMENT
64
from app.database.database import Base
5+
from sqlalchemy import (DDL, Boolean, Column, DateTime, ForeignKey, Index,
6+
Integer, String, event)
7+
from sqlalchemy.dialects.postgresql import TSVECTOR
8+
from sqlalchemy.orm import relationship
79

810

911
class UserEvent(Base):
@@ -54,10 +56,39 @@ class Event(Base):
5456

5557
participants = relationship("UserEvent", back_populates="events")
5658

59+
# PostgreSQL
60+
if PSQL_ENVIRONMENT:
61+
events_tsv = Column(TSVECTOR)
62+
__table_args__ = (Index(
63+
'events_tsv_idx',
64+
'events_tsv',
65+
postgresql_using='gin'),
66+
)
67+
5768
def __repr__(self):
5869
return f'<Event {self.id}>'
5970

6071

72+
class PSQLEnvironmentError(Exception):
73+
pass
74+
75+
76+
# PostgreSQL
77+
if PSQL_ENVIRONMENT:
78+
trigger_snippet = DDL("""
79+
CREATE TRIGGER ix_events_tsv_update BEFORE INSERT OR UPDATE
80+
ON events
81+
FOR EACH ROW EXECUTE PROCEDURE
82+
tsvector_update_trigger(events_tsv,'pg_catalog.english','title','content')
83+
""")
84+
85+
event.listen(
86+
Event.__table__,
87+
'after_create',
88+
trigger_snippet.execute_if(dialect='postgresql')
89+
)
90+
91+
6192
class Invitation(Base):
6293
__tablename__ = "invitations"
6394

app/internal/search.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from typing import List
2+
3+
from app.database.database import SessionLocal
4+
from app.database.models import Event
5+
from sqlalchemy.exc import SQLAlchemyError
6+
7+
8+
def get_stripped_keywords(keywords: str) -> str:
9+
'''Gets a string of keywords to search for from the user form
10+
and returns a stripped ready-to-db-search keywords string'''
11+
12+
keywords = " ".join(keywords.split())
13+
keywords = keywords.replace(" ", ":* & ") + ":*"
14+
return keywords
15+
16+
17+
def get_results_by_keywords(
18+
session: SessionLocal,
19+
keywords: str,
20+
owner_id: int
21+
) -> List[Event]:
22+
"""Returns possible results for a search in the 'events' database table
23+
24+
Args:
25+
keywords (str): search string
26+
owner_id (int): current user id
27+
28+
Returns:
29+
list: a list of events from the database matching the inserted keywords
30+
31+
Uses PostgreSQL's built in 'Full-text search' feature
32+
(doesn't work with SQLite)"""
33+
34+
keywords = get_stripped_keywords(keywords)
35+
36+
try:
37+
return session.query(Event).filter(
38+
Event.owner_id == owner_id,
39+
Event.events_tsv.match(keywords)).all()
40+
41+
except (SQLAlchemyError, AttributeError):
42+
return []

app/main.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
from fastapi import FastAPI, Request
22
from fastapi.staticfiles import StaticFiles
33

4+
from app.config import PSQL_ENVIRONMENT
45
from app.database import models
56
from app.database.database import engine
67
from app.dependencies import (
78
MEDIA_PATH, STATIC_PATH, templates)
8-
from app.routers import agenda, dayview, event, profile, email, invitation
9+
from app.routers import (agenda, dayview, email, event, invitation, profile,
10+
search)
911

1012

11-
models.Base.metadata.create_all(bind=engine)
13+
def create_tables(engine, psql_environment):
14+
if 'sqlite' in str(engine.url) and psql_environment:
15+
raise models.PSQLEnvironmentError(
16+
"You're trying to use PSQL features on SQLite env.\n"
17+
"Please set app.config.PSQL_ENVIRONMENT to False "
18+
"and run the app again."
19+
)
20+
else:
21+
models.Base.metadata.create_all(bind=engine)
1222

23+
24+
create_tables(engine, PSQL_ENVIRONMENT)
1325
app = FastAPI()
1426
app.mount("/static", StaticFiles(directory=STATIC_PATH), name="static")
1527
app.mount("/media", StaticFiles(directory=MEDIA_PATH), name="media")
@@ -20,6 +32,7 @@
2032
app.include_router(dayview.router)
2133
app.include_router(email.router)
2234
app.include_router(invitation.router)
35+
app.include_router(search.router)
2336

2437

2538
@app.get("/")

app/routers/search.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from app.database.database import get_db
2+
from app.dependencies import templates
3+
from app.internal.search import get_results_by_keywords
4+
from fastapi import APIRouter, Depends, Form, Request
5+
from sqlalchemy.orm import Session
6+
7+
8+
router = APIRouter()
9+
10+
11+
@router.get("/search")
12+
def search(request: Request):
13+
# Made up user details until there's a user login system
14+
current_username = "Chuck Norris"
15+
16+
return templates.TemplateResponse("search.html", {
17+
"request": request,
18+
"username": current_username
19+
})
20+
21+
22+
@router.post("/search")
23+
async def show_results(
24+
request: Request,
25+
keywords: str = Form(None),
26+
db: Session = Depends(get_db)):
27+
# Made up user details until there's a user login system
28+
current_username = "Chuck Norris"
29+
current_user = 1
30+
31+
message = ""
32+
33+
if not keywords:
34+
message = "Invalid request."
35+
results = None
36+
else:
37+
results = get_results_by_keywords(db, keywords, owner_id=current_user)
38+
if not results:
39+
message = f"No matching results for '{keywords}'."
40+
41+
return templates.TemplateResponse("search.html", {
42+
"request": request,
43+
"username": current_username,
44+
"message": message,
45+
"results": results,
46+
"keywords": keywords
47+
}
48+
)

app/templates/base.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@
4242
<li class="nav-item">
4343
<a class="nav-link" href="{{ url_for('view_invitations') }}">Invitations</a>
4444
</li>
45+
<li class="nav-item">
46+
<a class="nav-link" href="/search">Search</a>
47+
</li>
4548
</ul>
4649
</div>
4750
</div>

app/templates/profile.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,4 +246,4 @@ <h6 class="card-title text-center mb-1">{{ user.full_name }}</h6>
246246
</div>
247247
</div>
248248

249-
{% endblock %}
249+
{% endblock %}

app/templates/search.html

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
{% extends "base.html" %}
2+
3+
4+
{% block content %}
5+
6+
<div class="container mt-4">
7+
<h1>Hello, {{ username }}</h1>
8+
<form name="searchForm" action="/search" onsubmit="return validateForm()" method="POST" required>
9+
<div class="form-row align-items-center">
10+
<div class="col-sm-2 my-1">
11+
<label for="keywords" class="sr-only">Search event by keyword</label>
12+
<input type="text" id="keywords" name="keywords" class="form-control" placeholder="Keywords"
13+
value="{{ keywords }}" onfocus="this.value=''" required>
14+
</div>
15+
<div class="col my-1">
16+
<input type="submit" class="btn btn-primary" value="Search">
17+
</div>
18+
</form>
19+
</div>
20+
21+
{% if message %}
22+
<div class="container mt-4">
23+
{{ message }}
24+
</div>
25+
{% endif %}
26+
27+
<!-- Results -->
28+
{% if results %}
29+
<div class="container mt-4">
30+
<div class="my-4">
31+
Showing results for '{{ keywords }}':
32+
</div>
33+
<!-- Center -->
34+
<div class="col-5">
35+
<div class="mb-3">
36+
{% for result in results %}
37+
<!-- Events card -->
38+
<div class="card card-event mb-2 pb-0">
39+
<div class="card-header d-flex justify-content-between">
40+
<small>
41+
<i>{{ loop.index }}. {{ result.title }}</i>
42+
</small>
43+
</div>
44+
<div class="card-body pb-1">
45+
<p class="card-text">
46+
{{ result.content }}
47+
</p>
48+
<hr class="mb-0">
49+
<small class="event-posted-time text-muted">{{ result.date }}</small>
50+
</div>
51+
</div>
52+
<!-- End Events card -->
53+
{% endfor %}
54+
</div>
55+
</div>
56+
{% endif %}
57+
58+
<!-- Form field validation script -->
59+
<script>
60+
function validateForm() {
61+
var x = document.forms["searchForm"]["keywords"].value;
62+
if (x == "") {
63+
alert("Name must be filled out");
64+
return false;
65+
}
66+
}
67+
</script>
68+
<!-- End script -->
69+
{% endblock %}

0 commit comments

Comments
 (0)