Spaces:
Runtime error
Runtime error
Jessica Walkenhorst
commited on
Commit
β’
4f16d99
1
Parent(s):
05a3f3b
Refactor code (#15)
Browse files- Dockerfile +2 -2
- app.py +0 -79
- src/maorganizer/datawrangling.py +15 -9
- src/maorganizer/ui.py +268 -95
- tests/test_attendancelist.py +1 -1
Dockerfile
CHANGED
@@ -14,11 +14,11 @@ ENV POETRY_CACHE_DIR=/opt/.cache
|
|
14 |
|
15 |
WORKDIR /home/
|
16 |
|
17 |
-
COPY pyproject.toml poetry.lock
|
18 |
COPY src ./src/
|
19 |
|
20 |
RUN poetry install --without dev
|
21 |
|
22 |
EXPOSE 7860
|
23 |
|
24 |
-
ENTRYPOINT ["poetry", "run", "streamlit", "run", "
|
|
|
14 |
|
15 |
WORKDIR /home/
|
16 |
|
17 |
+
COPY pyproject.toml poetry.lock README.md ./
|
18 |
COPY src ./src/
|
19 |
|
20 |
RUN poetry install --without dev
|
21 |
|
22 |
EXPOSE 7860
|
23 |
|
24 |
+
ENTRYPOINT ["poetry", "run", "streamlit", "run", "src/maorganizer/ui.py", "--server.port", "7860"]
|
app.py
DELETED
@@ -1,79 +0,0 @@
|
|
1 |
-
from pathlib import Path
|
2 |
-
import streamlit as st
|
3 |
-
|
4 |
-
from maorganizer.datawrangling import Person
|
5 |
-
|
6 |
-
from maorganizer.ui import TASKS, render_xlsx_download_button, create_file_uploader, create_task_selector
|
7 |
-
|
8 |
-
# all these beautiful emojis from https://emojidb.org/file-emojis
|
9 |
-
|
10 |
-
st.title("π
Meeting Attendance Organizer")
|
11 |
-
st.markdown("This app fullfills a simple need: Take a list of names of people attending a meeting and peform one (or multiple) of the following tasks:")
|
12 |
-
st.markdown("""* βοΈ Split their names into first name and surname\n* π Compare two lists with each other and see who is new on the second list\n * π Find people in a list by either searching for their complete names or parts of their name\n * πΎ Write any of the results back out, so you can share it with others""")
|
13 |
-
|
14 |
-
st.header("π Step 1: Upload your Files")
|
15 |
-
st.markdown("Upload the file(s) containing your meeting attendees. The expected format is a single column containing the attendees' full names. If you column name is not Name, you will be able to specify the column name after uploading the data. Additional columns will be ignored.")
|
16 |
-
|
17 |
-
|
18 |
-
meetings = {}
|
19 |
-
meetings = create_file_uploader()
|
20 |
-
|
21 |
-
task = create_task_selector()
|
22 |
-
|
23 |
-
st.header("π₯ Step 3: Let's Go!")
|
24 |
-
st.subheader(f"You are going to ... {task}")
|
25 |
-
if not meetings:
|
26 |
-
st.info("β¬ You need to upload some data first β¬")
|
27 |
-
|
28 |
-
if meetings:
|
29 |
-
if task == TASKS.SPLIT.value:
|
30 |
-
|
31 |
-
filename = st.selectbox("Choose a file π", options=list(meetings.keys()), key=task)
|
32 |
-
|
33 |
-
render_xlsx_download_button({'Full list of Attendees': meetings[filename]},
|
34 |
-
filename=f"processed-attendees-{Path(filename).stem}.xlsx",
|
35 |
-
key=TASKS.SPLIT.value+'download')
|
36 |
-
|
37 |
-
elif task == TASKS.FIND.value:
|
38 |
-
|
39 |
-
filename = st.selectbox("π Choose a file", options=list(meetings.keys()), key=task)
|
40 |
-
attendees = meetings[filename]
|
41 |
-
|
42 |
-
textinput = st.text_input("π Who are you looking for? If you are looking for more than one, separate them by comma.")
|
43 |
-
st.markdown("β οΈ By default, the algorithm will surface any names that contain your search terms as substrings (e.g. if you search for Jon, it will surface both Jon and Jonathan). Tick the box below in case you want to display only entries where one of the names matches your search string exactly.")
|
44 |
-
st.markdown("β οΈβ οΈ Note that either way the search is case-insensitive.")
|
45 |
-
exact_match = st.checkbox("Find Exact Name")
|
46 |
-
|
47 |
-
if textinput.strip():
|
48 |
-
st.header("Search Results")
|
49 |
-
if exact_match:
|
50 |
-
people_to_find = [Person(word.strip()) for word in textinput.split(',')]
|
51 |
-
|
52 |
-
for to_find, found in attendees.find_people(people_to_find).items():
|
53 |
-
st.subheader(f"**{to_find.name}**")
|
54 |
-
st.markdown(f"{', '.join([p.name for p in found]) if found else 'Sorry, none found.'}")
|
55 |
-
else:
|
56 |
-
words_to_find = [word.strip() for word in textinput.split(',')]
|
57 |
-
|
58 |
-
for word_to_find, people_found in attendees.find_words(words_to_find).items():
|
59 |
-
st.subheader(f"**{word_to_find}**")
|
60 |
-
st.markdown(f"{', '.join([p.name for p in people_found]) if people_found else 'Sorry, none found.'}")
|
61 |
-
|
62 |
-
elif task == TASKS.COMPARE.value:
|
63 |
-
col1, col2 = st.columns(2)
|
64 |
-
with col1:
|
65 |
-
filename_old = st.selectbox("Choose your original file", options=list(meetings.keys()), key=task)
|
66 |
-
with col2:
|
67 |
-
filename_new = st.selectbox("Choose your updated file", options=set(meetings.keys()) - {filename_old})
|
68 |
-
|
69 |
-
# filename_new gets automatically populated if there is more than one file
|
70 |
-
# so if there is none, it's because there is only a single file available and no options left for filename_new
|
71 |
-
if filename_new is None:
|
72 |
-
st.info("β¬ Please upload a second file. β¬")
|
73 |
-
else:
|
74 |
-
listcomparison = (
|
75 |
-
{'Original List': meetings[filename_old],
|
76 |
-
'Updated List - Full': meetings[filename_new],
|
77 |
-
'Updated List - Only Updates': meetings[filename_old].update(meetings[filename_new])})
|
78 |
-
|
79 |
-
render_xlsx_download_button(listcomparison, filename=f"{Path(filename_old).stem}-updated.xlsx", key=TASKS.COMPARE.value+'download')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/maorganizer/datawrangling.py
CHANGED
@@ -5,6 +5,9 @@ from typing import List, Set
|
|
5 |
|
6 |
import pandas as pd
|
7 |
|
|
|
|
|
|
|
8 |
DATAFOLDER = Path().cwd() / "data"
|
9 |
|
10 |
NAMECOLUMN = "Name"
|
@@ -46,15 +49,15 @@ class Person:
|
|
46 |
class Attendancelist:
|
47 |
participants: Set[Person]
|
48 |
|
49 |
-
def
|
50 |
return Attendancelist({Person(name) for name in df[cname]})
|
51 |
|
52 |
-
def
|
53 |
filename: pathlib.PosixPath, cname: str = NAMECOLUMN, sep: str = None
|
54 |
):
|
55 |
-
if filename.suffix in
|
56 |
df = pd.read_excel(filename)
|
57 |
-
elif filename.suffix
|
58 |
df = pd.read_csv(filename, sep=sep)
|
59 |
else:
|
60 |
raise ValueError(
|
@@ -62,7 +65,7 @@ class Attendancelist:
|
|
62 |
"of the following filetypes: .xlsx, .xls, .csv"
|
63 |
)
|
64 |
|
65 |
-
return Attendancelist.
|
66 |
|
67 |
@property
|
68 |
def n_attendees(self):
|
@@ -99,8 +102,11 @@ class Attendancelist:
|
|
99 |
def find_people(self, people: List[Person]):
|
100 |
return {p: self.find_person(p) for p in people}
|
101 |
|
102 |
-
def
|
103 |
-
|
|
|
|
|
|
|
104 |
|
105 |
-
def
|
106 |
-
return {
|
|
|
5 |
|
6 |
import pandas as pd
|
7 |
|
8 |
+
CSV_EXTENSIONS = [".csv", ".txt"]
|
9 |
+
EXCEL_EXTENSIONS = [".xls", ".xlsx"]
|
10 |
+
|
11 |
DATAFOLDER = Path().cwd() / "data"
|
12 |
|
13 |
NAMECOLUMN = "Name"
|
|
|
49 |
class Attendancelist:
|
50 |
participants: Set[Person]
|
51 |
|
52 |
+
def from_df(df, cname: str = NAMECOLUMN):
|
53 |
return Attendancelist({Person(name) for name in df[cname]})
|
54 |
|
55 |
+
def from_file(
|
56 |
filename: pathlib.PosixPath, cname: str = NAMECOLUMN, sep: str = None
|
57 |
):
|
58 |
+
if filename.suffix in EXCEL_EXTENSIONS:
|
59 |
df = pd.read_excel(filename)
|
60 |
+
elif filename.suffix in CSV_EXTENSIONS:
|
61 |
df = pd.read_csv(filename, sep=sep)
|
62 |
else:
|
63 |
raise ValueError(
|
|
|
65 |
"of the following filetypes: .xlsx, .xls, .csv"
|
66 |
)
|
67 |
|
68 |
+
return Attendancelist.from_df(df, cname)
|
69 |
|
70 |
@property
|
71 |
def n_attendees(self):
|
|
|
102 |
def find_people(self, people: List[Person]):
|
103 |
return {p: self.find_person(p) for p in people}
|
104 |
|
105 |
+
def find_by_string(self, word: str, exact=False):
|
106 |
+
if exact:
|
107 |
+
return self.find_person(Person(word))
|
108 |
+
else:
|
109 |
+
return {p for p in self.participants if p.name_contains(word.lower())}
|
110 |
|
111 |
+
def find_by_strings(self, to_be_found: List[str], exact=False):
|
112 |
+
return {tbf: self.find_by_string(tbf, exact=exact) for tbf in to_be_found}
|
src/maorganizer/ui.py
CHANGED
@@ -1,77 +1,250 @@
|
|
1 |
import io
|
2 |
-
|
|
|
3 |
from pathlib import Path
|
4 |
-
from typing import Dict
|
5 |
|
6 |
import pandas as pd
|
7 |
import streamlit as st
|
8 |
|
9 |
-
from maorganizer.datawrangling import
|
|
|
|
|
|
|
|
|
|
|
10 |
|
11 |
-
CSV_EXTENSIONS = [".csv", ".txt"]
|
12 |
-
EXCEL_EXTENSIONS = [".xls", ".xlsx"]
|
13 |
ACCEPTED_EXTENSIONS = CSV_EXTENSIONS + EXCEL_EXTENSIONS
|
14 |
|
15 |
-
|
16 |
SEPARATORTYPES = {"TAB": "\t", "COMMA": ","}
|
17 |
|
18 |
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
|
|
|
|
23 |
|
24 |
-
def __str__(self) -> str: # makes enum values duck-type to strings
|
25 |
-
return str.__str__(self)
|
26 |
|
|
|
|
|
|
|
27 |
|
28 |
-
def
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
49 |
else:
|
50 |
-
|
51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
)
|
53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
54 |
|
55 |
|
56 |
-
def
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
load_df_from_uploaded_data(file.name, file, sep), cname=cname
|
62 |
)
|
63 |
-
|
64 |
-
|
|
|
65 |
|
66 |
|
67 |
-
def load_data(uploaded_files) ->
|
68 |
def _files_contain_csv(uploaded_files) -> bool:
|
69 |
return bool(
|
70 |
sum([Path(file.name).suffix in CSV_EXTENSIONS for file in uploaded_files])
|
71 |
)
|
72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
73 |
try:
|
74 |
-
|
75 |
uploaded_files, sep=None, cname=NAMECOLUMN
|
76 |
)
|
77 |
|
@@ -96,7 +269,7 @@ def load_data(uploaded_files) -> Tuple[Dict, bool]:
|
|
96 |
)
|
97 |
|
98 |
try:
|
99 |
-
|
100 |
uploaded_files, sep=separator, cname=columnname
|
101 |
)
|
102 |
except KeyError:
|
@@ -105,69 +278,69 @@ def load_data(uploaded_files) -> Tuple[Dict, bool]:
|
|
105 |
" Please use the options above to specify your column separator (if text/csv file)"
|
106 |
" and the column name of the column containing your attendees' names."
|
107 |
)
|
108 |
-
|
109 |
|
110 |
-
if
|
111 |
st.success(
|
112 |
"Successfully loaded the following files:\\\n\\\n"
|
113 |
-
+ "\\\n".join(
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
def render_file_selector(meetings, key):
|
119 |
-
show_processed_list = st.checkbox("Display the processed list of attendees")
|
120 |
-
if show_processed_list:
|
121 |
-
filename = st.selectbox(
|
122 |
-
"Select the file to display", options=list(meetings.keys()), key=key
|
123 |
)
|
124 |
-
|
125 |
|
126 |
-
st.write(attendees.to_df())
|
127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
128 |
|
129 |
-
def create_file_uploader():
|
130 |
uploaded_files = st.file_uploader(
|
131 |
label="π Upload your files", accept_multiple_files=True
|
132 |
)
|
|
|
|
|
133 |
if uploaded_files:
|
134 |
meetings = load_data(uploaded_files)
|
135 |
-
|
136 |
-
return meetings
|
137 |
-
else:
|
138 |
-
return {}
|
139 |
|
|
|
140 |
|
141 |
-
def create_task_selector():
|
142 |
-
st.header("π Step 2: Choose a Task")
|
143 |
-
task = st.radio("I would like to ...", [task.value for task in TASKS])
|
144 |
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
|
159 |
|
160 |
-
def
|
161 |
-
|
162 |
-
with pd.ExcelWriter(output, engine="openpyxl") as writer:
|
163 |
-
for sheetname, attendees in data.items():
|
164 |
-
attendees.to_df().to_excel(writer, sheet_name=sheetname, index=False)
|
165 |
-
writer.save()
|
166 |
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
import io
|
2 |
+
import types
|
3 |
+
from dataclasses import dataclass
|
4 |
from pathlib import Path
|
5 |
+
from typing import Dict
|
6 |
|
7 |
import pandas as pd
|
8 |
import streamlit as st
|
9 |
|
10 |
+
from maorganizer.datawrangling import (
|
11 |
+
CSV_EXTENSIONS,
|
12 |
+
EXCEL_EXTENSIONS,
|
13 |
+
NAMECOLUMN,
|
14 |
+
Attendancelist,
|
15 |
+
)
|
16 |
|
|
|
|
|
17 |
ACCEPTED_EXTENSIONS = CSV_EXTENSIONS + EXCEL_EXTENSIONS
|
18 |
|
|
|
19 |
SEPARATORTYPES = {"TAB": "\t", "COMMA": ","}
|
20 |
|
21 |
|
22 |
+
@dataclass
|
23 |
+
class Task:
|
24 |
+
abbreviation: str
|
25 |
+
short_description: str
|
26 |
+
long_description: str
|
27 |
+
run: types.FunctionType
|
28 |
|
|
|
|
|
29 |
|
30 |
+
@dataclass
|
31 |
+
class Tasks:
|
32 |
+
tasks: list[Task]
|
33 |
|
34 |
+
def __post_init__(self):
|
35 |
+
assert len(self.abbreviations) == len(set(self.abbreviations))
|
36 |
+
assert len(self.short_descriptions) == len(set(self.abbreviations))
|
37 |
+
|
38 |
+
@property
|
39 |
+
def abbreviations(self):
|
40 |
+
return [task.abbreviation for task in self.tasks]
|
41 |
+
|
42 |
+
@property
|
43 |
+
def short_descriptions(self):
|
44 |
+
return [task.short_description for task in self.tasks]
|
45 |
+
|
46 |
+
|
47 |
+
def split_names(meetings: Dict[str, Attendancelist]) -> None:
|
48 |
+
filename = st.selectbox(
|
49 |
+
"Choose a file π", options=list(meetings.keys()), key="split_names"
|
50 |
+
)
|
51 |
+
|
52 |
+
render_xlsx_download_button(
|
53 |
+
{"Full list of Attendees": meetings[filename]},
|
54 |
+
filename=f"processed-attendees-{Path(filename).stem}.xlsx",
|
55 |
+
)
|
56 |
+
|
57 |
+
|
58 |
+
def find_attendees(meetings: Dict[str, Attendancelist]) -> None:
|
59 |
+
def _render_findings(to_be_found, people_found):
|
60 |
+
st.subheader(f"**{to_be_found}**")
|
61 |
+
st.markdown(
|
62 |
+
f"{', '.join([p.name for p in people_found]) if people_found else 'Sorry, none found.'}"
|
63 |
+
)
|
64 |
+
|
65 |
+
filename = st.selectbox(
|
66 |
+
"π Choose a file", options=list(meetings.keys()), key="find_attendees"
|
67 |
+
)
|
68 |
+
attendees = meetings[filename]
|
69 |
+
|
70 |
+
textinput = st.text_input(
|
71 |
+
"π Who are you looking for? If you are looking for more than one, separate them by comma."
|
72 |
+
)
|
73 |
+
if textinput:
|
74 |
+
searchterms = [(word.strip()) for word in textinput.strip().split(",")]
|
75 |
else:
|
76 |
+
searchterms = []
|
77 |
+
|
78 |
+
st.markdown(
|
79 |
+
"β οΈ By default, the algorithm will surface any names that contain your search terms"
|
80 |
+
" as substrings (e.g. if you search for Jon, it will surface both Jon and Jonathan)."
|
81 |
+
" Tick the box below in case you want to display only entries where one of the names"
|
82 |
+
" matches your search string exactly."
|
83 |
+
)
|
84 |
+
st.markdown("β οΈβ οΈ Note that either way the search is case-insensitive.")
|
85 |
+
exact_match = st.checkbox("Find Exact Name")
|
86 |
+
|
87 |
+
if searchterms:
|
88 |
+
st.header("Search Results")
|
89 |
+
|
90 |
+
for to_be_found, found in attendees.find_by_strings(
|
91 |
+
searchterms, exact=exact_match
|
92 |
+
).items():
|
93 |
+
_render_findings(to_be_found, found)
|
94 |
+
|
95 |
+
|
96 |
+
def compare_meetings(meetings: Dict[str, Attendancelist]) -> None:
|
97 |
+
col1, col2 = st.columns(2)
|
98 |
+
with col1:
|
99 |
+
filename_old = st.selectbox(
|
100 |
+
"Choose your original file", options=list(meetings.keys())
|
101 |
+
)
|
102 |
+
with col2:
|
103 |
+
filename_new = st.selectbox(
|
104 |
+
"Choose your updated file", options=set(meetings.keys()) - {filename_old}
|
105 |
)
|
106 |
+
|
107 |
+
# filename_new gets automatically populated if there is more than one file
|
108 |
+
# so if there is none, it's because there is only a single file available
|
109 |
+
# and no options left for filename_new
|
110 |
+
if filename_new is None:
|
111 |
+
st.info("β¬ Please upload a second file. β¬")
|
112 |
+
else:
|
113 |
+
listcomparison = {
|
114 |
+
"Original List": meetings[filename_old],
|
115 |
+
"Updated List - Full": meetings[filename_new],
|
116 |
+
"Updated List - Only Updates": meetings[filename_old].update(
|
117 |
+
meetings[filename_new]
|
118 |
+
),
|
119 |
+
}
|
120 |
+
|
121 |
+
render_xlsx_download_button(
|
122 |
+
listcomparison,
|
123 |
+
filename=f"{Path(filename_old).stem}-updated.xlsx",
|
124 |
+
)
|
125 |
+
|
126 |
+
|
127 |
+
def create_tasks() -> Tasks:
|
128 |
+
return Tasks(
|
129 |
+
[
|
130 |
+
Task(
|
131 |
+
abbreviation="SPLIT",
|
132 |
+
short_description=(
|
133 |
+
"βοΈ ... split attendees into first"
|
134 |
+
"and last name and download results"
|
135 |
+
),
|
136 |
+
long_description=(
|
137 |
+
"β **Description:** Split a list of names"
|
138 |
+
" into first and surname."
|
139 |
+
),
|
140 |
+
run=split_names,
|
141 |
+
),
|
142 |
+
Task(
|
143 |
+
abbreviation="COMPARE",
|
144 |
+
short_description=" π ... compare two meetings with each other and find updates",
|
145 |
+
long_description=(
|
146 |
+
"β **Description:** Compare two attendee lists"
|
147 |
+
" with each and find attendees who have recently joined."
|
148 |
+
),
|
149 |
+
run=compare_meetings,
|
150 |
+
),
|
151 |
+
Task(
|
152 |
+
abbreviation="FIND",
|
153 |
+
short_description=" π ... find specific attendees",
|
154 |
+
long_description=(
|
155 |
+
"β **Description:** Find attendees in a list"
|
156 |
+
" by either first name or surname or by substrings."
|
157 |
+
),
|
158 |
+
run=find_attendees,
|
159 |
+
),
|
160 |
+
]
|
161 |
+
)
|
162 |
+
|
163 |
+
|
164 |
+
def render_xlsx_download_button(
|
165 |
+
meetings: Dict[str, Attendancelist], filename: str
|
166 |
+
) -> None:
|
167 |
+
with io.BytesIO() as output:
|
168 |
+
with pd.ExcelWriter(output, engine="openpyxl") as writer:
|
169 |
+
for sheetname, attendees in meetings.items():
|
170 |
+
attendees.to_df().to_excel(writer, sheet_name=sheetname, index=False)
|
171 |
+
|
172 |
+
st.download_button(
|
173 |
+
label="πΎ Download Results",
|
174 |
+
data=output.getvalue(),
|
175 |
+
file_name=filename,
|
176 |
+
mime="application/vnd.ms-excel",
|
177 |
+
)
|
178 |
+
|
179 |
+
|
180 |
+
def select_task(tasks: Tasks) -> Task:
|
181 |
+
selected_task = st.radio("I would like to ...", tasks.short_descriptions)
|
182 |
+
|
183 |
+
for task in tasks.tasks:
|
184 |
+
if task.short_description == selected_task:
|
185 |
+
st.markdown(task.long_description)
|
186 |
+
break
|
187 |
+
|
188 |
+
return task
|
189 |
|
190 |
|
191 |
+
def render_file_analysis(meetings: Dict[str, Attendancelist]) -> None:
|
192 |
+
show_processed_list = st.checkbox("Display the processed list of attendees")
|
193 |
+
if show_processed_list:
|
194 |
+
filename = st.selectbox(
|
195 |
+
"Select the file to display", options=list(meetings.keys())
|
|
|
196 |
)
|
197 |
+
attendees = meetings[filename]
|
198 |
+
|
199 |
+
st.write(attendees.to_df())
|
200 |
|
201 |
|
202 |
+
def load_data(uploaded_files) -> Dict[str, Attendancelist]:
|
203 |
def _files_contain_csv(uploaded_files) -> bool:
|
204 |
return bool(
|
205 |
sum([Path(file.name).suffix in CSV_EXTENSIONS for file in uploaded_files])
|
206 |
)
|
207 |
|
208 |
+
def _load_df_from_uploaded_data(filename: str, data, sep=None) -> pd.DataFrame:
|
209 |
+
if Path(filename).suffix in EXCEL_EXTENSIONS:
|
210 |
+
try:
|
211 |
+
df = pd.read_excel(data)
|
212 |
+
# If engine does not recognize excel as excel, it is likely to be
|
213 |
+
# a text format "disguised" as xls.
|
214 |
+
# (example: PyData Meeting files have .xls extension, but are in fact text files)
|
215 |
+
except ValueError as e:
|
216 |
+
if str(e) == (
|
217 |
+
"Excel file format cannot be determined,"
|
218 |
+
" you must specify an engine manually."
|
219 |
+
):
|
220 |
+
st.info(
|
221 |
+
f"Your {Path(filename).suffix} file does not seem"
|
222 |
+
" to be an excel file.\\\n\\\n"
|
223 |
+
"\- Trying to parse it as text file."
|
224 |
+
)
|
225 |
+
df = pd.read_csv(data, sep=sep)
|
226 |
+
else:
|
227 |
+
raise ValueError(e)
|
228 |
+
elif Path(filename).suffix in CSV_EXTENSIONS:
|
229 |
+
df = pd.read_csv(data, sep=sep)
|
230 |
+
else:
|
231 |
+
raise ValueError(
|
232 |
+
f"Please choose one of the following extensions: {', '.join(ACCEPTED_EXTENSIONS)}"
|
233 |
+
)
|
234 |
+
return df
|
235 |
+
|
236 |
+
def _make_attendance_data_from_file_uploads(
|
237 |
+
uploaded_files, sep=None, cname=NAMECOLUMN
|
238 |
+
) -> Dict[str, Attendancelist]:
|
239 |
+
return {
|
240 |
+
file.name: Attendancelist.from_df(
|
241 |
+
_load_df_from_uploaded_data(file.name, file, sep), cname=cname
|
242 |
+
)
|
243 |
+
for file in uploaded_files
|
244 |
+
}
|
245 |
+
|
246 |
try:
|
247 |
+
meetings = _make_attendance_data_from_file_uploads(
|
248 |
uploaded_files, sep=None, cname=NAMECOLUMN
|
249 |
)
|
250 |
|
|
|
269 |
)
|
270 |
|
271 |
try:
|
272 |
+
meetings = _make_attendance_data_from_file_uploads(
|
273 |
uploaded_files, sep=separator, cname=columnname
|
274 |
)
|
275 |
except KeyError:
|
|
|
278 |
" Please use the options above to specify your column separator (if text/csv file)"
|
279 |
" and the column name of the column containing your attendees' names."
|
280 |
)
|
281 |
+
meetings = {}
|
282 |
|
283 |
+
if meetings:
|
284 |
st.success(
|
285 |
"Successfully loaded the following files:\\\n\\\n"
|
286 |
+
+ "\\\n".join(
|
287 |
+
[f"{k} - {v.n_attendees} attendees" for k, v in meetings.items()]
|
288 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
289 |
)
|
290 |
+
return meetings
|
291 |
|
|
|
292 |
|
293 |
+
def upload_files() -> Dict[str, Attendancelist]:
|
294 |
+
st.markdown(
|
295 |
+
"Upload the file(s) containing your meeting attendees. The expected format is a single"
|
296 |
+
" column containing the attendees' full names. If you column name is not Name,"
|
297 |
+
" you will be able to specify the column name after uploading the data."
|
298 |
+
" Additional columns will be ignored."
|
299 |
+
)
|
300 |
|
|
|
301 |
uploaded_files = st.file_uploader(
|
302 |
label="π Upload your files", accept_multiple_files=True
|
303 |
)
|
304 |
+
|
305 |
+
meetings = {}
|
306 |
if uploaded_files:
|
307 |
meetings = load_data(uploaded_files)
|
308 |
+
render_file_analysis(meetings)
|
|
|
|
|
|
|
309 |
|
310 |
+
return meetings
|
311 |
|
|
|
|
|
|
|
312 |
|
313 |
+
def render_intro() -> None:
|
314 |
+
st.title("π
Meeting Attendance Organizer")
|
315 |
+
st.markdown(
|
316 |
+
"This app fullfills a simple need: Take a list of names of people"
|
317 |
+
" attending a meeting and peform one (or multiple) of the following tasks:"
|
318 |
+
)
|
319 |
+
st.markdown(
|
320 |
+
"* βοΈ Split their names into first name and surname\n"
|
321 |
+
"* π Compare two lists with each other and see who is new on the second list\n"
|
322 |
+
"* π Find people in a list by either searching for their complete names or parts"
|
323 |
+
" of their name\n"
|
324 |
+
"* πΎ Write any of the results back out, so you can share it with others"
|
325 |
+
)
|
326 |
|
327 |
|
328 |
+
def main():
|
329 |
+
render_intro()
|
|
|
|
|
|
|
|
|
330 |
|
331 |
+
st.header("π Step 1: Upload your Files")
|
332 |
+
meetings = upload_files()
|
333 |
+
|
334 |
+
st.header("π Step 2: Choose a Task")
|
335 |
+
task = select_task(tasks=create_tasks())
|
336 |
+
|
337 |
+
st.header("π₯ Step 3: Let's Go!")
|
338 |
+
st.subheader(f"You are going to ... {task.short_description}")
|
339 |
+
if not meetings:
|
340 |
+
st.info("β¬ You need to upload some data first β¬")
|
341 |
+
else:
|
342 |
+
task.run(meetings)
|
343 |
+
|
344 |
+
|
345 |
+
if __name__ == "__main__":
|
346 |
+
main()
|
tests/test_attendancelist.py
CHANGED
@@ -4,7 +4,7 @@ from maorganizer.datawrangling import Attendancelist, Person
|
|
4 |
def test_attendancelist_finds_person_by_substring():
|
5 |
assert Attendancelist(
|
6 |
{Person("zaphod beeblebrox"), Person("ford prefix")}
|
7 |
-
).
|
8 |
|
9 |
|
10 |
def test_attendancelist_finds_person_by_namepart():
|
|
|
4 |
def test_attendancelist_finds_person_by_substring():
|
5 |
assert Attendancelist(
|
6 |
{Person("zaphod beeblebrox"), Person("ford prefix")}
|
7 |
+
).find_by_string("aph") == {Person("Zaphod Beeblebrox")}
|
8 |
|
9 |
|
10 |
def test_attendancelist_finds_person_by_namepart():
|