PageRenderTime 27ms CodeModel.GetById 26ms RepoModel.GetById 0ms app.codeStats 0ms

/timApp/plugin/tableform/tableForm.py

https://gitlab.com/tim-jyu/tim
Python | 759 lines | 725 code | 24 blank | 10 comment | 15 complexity | 2bad40830ed9a6aaac447dbd66ed12f7 MD5 | raw file
  1. """
  2. TIM example plugin: a tableFormndrome checker.
  3. """
  4. import json
  5. from dataclasses import dataclass, asdict, field
  6. from typing import Any, TypedDict, Sequence
  7. from flask import render_template_string, Response
  8. from marshmallow.utils import missing
  9. from sqlalchemy.orm import joinedload
  10. from webargs.flaskparser import use_args
  11. from timApp.auth.accesshelper import get_doc_or_abort, AccessDenied
  12. from timApp.auth.sessioninfo import get_current_user_object
  13. from timApp.document.docinfo import DocInfo
  14. from timApp.document.timjsonencoder import TimJsonEncoder
  15. from timApp.document.usercontext import UserContext
  16. from timApp.document.viewcontext import ViewRoute, ViewContext
  17. from timApp.item.block import Block
  18. from timApp.item.tag import Tag, TagType, GROUP_TAG_PREFIX
  19. from timApp.plugin.jsrunner import jsrunner_run, JsRunnerParams, JsRunnerError
  20. from timApp.plugin.plugin import (
  21. find_plugin_from_document,
  22. TaskNotFoundException,
  23. Plugin,
  24. )
  25. from timApp.plugin.tableform.comparatorFilter import RegexOrComparator
  26. from timApp.plugin.taskid import TaskId
  27. from timApp.sisu.parse_display_name import parse_sisu_group_display_name
  28. from timApp.sisu.sisu import get_potential_groups
  29. from timApp.tim_app import csrf
  30. from timApp.user.user import User, get_membership_end, get_membership_added
  31. from timApp.user.usergroup import UserGroup
  32. from timApp.util.flask.requesthelper import (
  33. RouteException,
  34. use_model,
  35. view_ctx_with_urlmacros,
  36. NotExist,
  37. )
  38. from timApp.util.flask.responsehelper import csv_string, json_response, text_response
  39. from timApp.util.get_fields import (
  40. get_fields_and_users,
  41. MembershipFilter,
  42. UserFields,
  43. RequestedGroups,
  44. GetFieldsAccess,
  45. )
  46. from timApp.util.utils import fin_timezone
  47. from tim_common.markupmodels import GenericMarkupModel
  48. from tim_common.marshmallow_dataclass import class_schema
  49. from tim_common.pluginserver_flask import (
  50. GenericHtmlModel,
  51. GenericAnswerModel,
  52. create_blueprint,
  53. value_or_default,
  54. PluginAnswerResp,
  55. PluginAnswerWeb,
  56. PluginReqs,
  57. EditorTab,
  58. )
  59. from tim_common.utils import Missing
  60. @dataclass
  61. class TableFormStateModel:
  62. """Model for the information that is stored in TIM database for each answer."""
  63. # TODO: Save user given table layouts like in timTable
  64. @dataclass
  65. class DataViewVirtualScrollingModel:
  66. enabled: bool | None = True
  67. verticalOverflow: int | Missing = missing
  68. horizontalOverflow: int | Missing = missing
  69. @dataclass
  70. class DataViewSettingsModel:
  71. virtual: DataViewVirtualScrollingModel | Missing | None = missing
  72. rowHeight: int | Missing = missing
  73. columnWidths: dict[str, int] | Missing = missing
  74. tableWidth: str | Missing = missing
  75. fixedColumns: int | Missing = missing
  76. @dataclass
  77. class RunScriptModel:
  78. script: str | None = None
  79. button: str | None = None
  80. all: bool | None = None
  81. update: bool | None = None
  82. interval: int | None = None
  83. @dataclass
  84. class TableFormMarkupModel(GenericMarkupModel):
  85. anonNames: bool | Missing = missing
  86. autosave: bool | Missing = missing
  87. autoUpdateFields: bool | Missing = True
  88. autoUpdateTables: bool | Missing = True
  89. cbColumn: bool | Missing | None = missing
  90. dataCollection: str | Missing | None = missing
  91. emails: bool | Missing = missing
  92. addedDates: bool | Missing = missing
  93. emailUsersButtonText: str | Missing | None = missing
  94. filterRow: bool | Missing | None = missing
  95. fixedColor: str | Missing | None = missing
  96. fontSize: str | Missing | None = missing
  97. forceUpdateButtonText: str | Missing | None = missing
  98. groups: list[str] | Missing = missing
  99. hiddenColumns: list[int] | Missing | None = missing
  100. hiddenRows: list[int] | Missing | None = missing
  101. hide: dict[Any, Any] | Missing | None = missing
  102. hideButtonText: str | Missing | None = missing
  103. includeUsers: MembershipFilter = field(
  104. default=MembershipFilter.Current, metadata={"by_value": True}
  105. )
  106. lockedFields: list[str] | Missing = missing
  107. maxCols: str | Missing | None = missing
  108. maxRows: str | Missing | None = missing
  109. maxWidth: str | Missing = missing
  110. minWidth: str | Missing | None = missing
  111. nrColumn: bool | Missing | None = missing
  112. charRow: bool | Missing | None = missing
  113. open: bool | Missing = True
  114. openButtonText: str | Missing | None = missing
  115. realnames: bool | Missing = missing
  116. removeDocIds: bool | Missing = True
  117. removeUsersButtonText: str | Missing | None = missing
  118. report: bool | Missing = missing
  119. reportButton: str | Missing | None = missing
  120. reportFilter: str | Missing | None = missing
  121. runScripts: list[str | RunScriptModel] | Missing = missing
  122. saveStyles: bool | Missing = True
  123. separator: str | Missing | None = missing
  124. showToolbar: bool | Missing | None = missing
  125. singleLine: bool | Missing | None = missing
  126. sisugroups: str | Missing = missing
  127. sortBy: str | Missing | None = missing
  128. table: bool | Missing = missing
  129. toolbarTemplates: list[dict[Any, Any]] | Missing = missing
  130. userListButtonText: str | Missing | None = missing
  131. usernames: bool | Missing = missing
  132. dataView: DataViewSettingsModel | Missing | None = missing
  133. TableFormMarkupSchema = class_schema(TableFormMarkupModel)
  134. @dataclass
  135. class TableFormInputModel:
  136. """Model for the information that is sent from browser (plugin AngularJS component)."""
  137. replyRows: dict[int, Any]
  138. nosave: bool | Missing = missing
  139. def get_sisu_group_desc_for_table(g: UserGroup) -> str:
  140. p = parse_sisu_group_display_name(g.display_name)
  141. assert p is not None
  142. if g.external_id.is_studysubgroup:
  143. return p.desc
  144. # We want the most important groups to be at the top of the table.
  145. # The '(' at the beginning changes the sort order.
  146. return f"({p.desc})"
  147. def get_sisugroups(user: User, sisu_id: str | None) -> "TableFormObj":
  148. gs = get_potential_groups(user, sisu_id)
  149. docs_with_course_tag = (
  150. Tag.query.filter_by(type=TagType.CourseCode)
  151. .with_entities(Tag.block_id)
  152. .subquery()
  153. )
  154. tags = (
  155. Tag.query.filter(
  156. Tag.name.in_([GROUP_TAG_PREFIX + g.name for g in gs])
  157. & Tag.block_id.in_(docs_with_course_tag)
  158. )
  159. .options(joinedload(Tag.block).joinedload(Block.docentries))
  160. .all()
  161. )
  162. tag_map = {t.name[len(GROUP_TAG_PREFIX) :]: t for t in tags}
  163. def get_course_page(ug: UserGroup) -> str | None:
  164. t: Tag | None = tag_map.get(ug.name)
  165. if t:
  166. return f'<a href="{t.block.docentries[0].url_relative}">URL</a>'
  167. else:
  168. return None
  169. return TableFormObj(
  170. rows={
  171. g.external_id.external_id: {
  172. "TIM-nimi": g.name,
  173. "URL": f'<a href="{g.admin_doc.docentries[0].url_relative}">URL</a>'
  174. if g.admin_doc
  175. else None,
  176. "Jäseniä": len(g.current_memberships),
  177. "Kurssisivu": get_course_page(g),
  178. }
  179. for g in gs
  180. },
  181. users={
  182. g.external_id.external_id: TableFormUserInfo(
  183. real_name=get_sisu_group_desc_for_table(g)
  184. if sisu_id
  185. else g.display_name,
  186. # The rows are not supposed to match any real user when handling sisu groups,
  187. # so we try to use an id value that does not match anyone.
  188. id=-100000,
  189. email="",
  190. )
  191. for g in gs
  192. },
  193. fields=["Jäseniä", "TIM-nimi", "URL", "Kurssisivu"],
  194. aliases={
  195. "TIM-nimi": "TIM-nimi",
  196. "URL": "URL",
  197. "Jäseniä": "Jäseniä",
  198. "Kurssisivu": "Kurssisivu",
  199. },
  200. styles={g.external_id.external_id: {} for g in gs},
  201. membership_add={},
  202. membership_end={},
  203. )
  204. @dataclass
  205. class TableFormHtmlModel(
  206. GenericHtmlModel[TableFormInputModel, TableFormMarkupModel, TableFormStateModel]
  207. ):
  208. def get_component_html_name(self) -> str:
  209. return "tableform-runner"
  210. def show_in_view_default(self) -> bool:
  211. return False
  212. def get_json_encoder(self) -> type[TimJsonEncoder]:
  213. return TimJsonEncoder
  214. def get_static_html(self) -> str:
  215. return render_static_table_form(self)
  216. def get_browser_json(self) -> dict:
  217. r = super().get_browser_json()
  218. if self.markup.open:
  219. doc_id = TaskId.parse_doc_id(self.taskID)
  220. d = get_doc_or_abort(doc_id)
  221. user = User.get_by_name(self.current_user_id)
  222. assert user is not None
  223. if isinstance(self.markup.sisugroups, str):
  224. f = get_sisugroups(user, self.markup.sisugroups)
  225. else:
  226. f = tableform_get_fields(
  227. value_or_default(self.markup.fields, []),
  228. value_or_default(self.markup.groups, []),
  229. d,
  230. user,
  231. ViewContext(
  232. ViewRoute.View if self.viewmode else ViewRoute.Teacher,
  233. self.preview,
  234. ),
  235. value_or_default(self.markup.removeDocIds, True),
  236. value_or_default(self.markup.showInView, False),
  237. group_filter_type=self.markup.includeUsers,
  238. )
  239. r = {**r, **f}
  240. return r
  241. @dataclass
  242. class TableFormAnswerModel(
  243. GenericAnswerModel[TableFormInputModel, TableFormMarkupModel, TableFormStateModel]
  244. ):
  245. pass
  246. def render_static_table_form(m: TableFormHtmlModel) -> str:
  247. return render_template_string(
  248. """
  249. <div class="tableform">
  250. <button class="timButton">
  251. Avaa Taulukko/Raporttinäkymä
  252. </button>
  253. </div>
  254. <br>
  255. """,
  256. **asdict(m.markup),
  257. )
  258. @dataclass
  259. class GenerateCSVModel:
  260. docId: int
  261. fields: list[str]
  262. groups: list[str]
  263. separator: str
  264. userFilter: list[str] = field(default_factory=list)
  265. usernames: bool | Missing = True
  266. realnames: bool | Missing = missing
  267. removeDocIds: bool | Missing = missing
  268. emails: bool | Missing = missing
  269. reportFilter: str | Missing = missing
  270. filterFields: list[str] = field(default_factory=list)
  271. filterValues: list[str] = field(default_factory=list)
  272. GenerateCSVSchema = class_schema(GenerateCSVModel)
  273. class TableformAnswerResp(PluginAnswerResp):
  274. savedata: list[dict[str, Any]]
  275. def answer(args: TableFormAnswerModel) -> PluginAnswerResp:
  276. rows = args.input.replyRows
  277. save_rows = []
  278. for uid, r in rows.items():
  279. save_rows.append({"user": uid, "fields": r})
  280. web: PluginAnswerWeb = {}
  281. result: TableformAnswerResp = {"web": web, "savedata": save_rows}
  282. web["result"] = "saved"
  283. return result
  284. def reqs() -> PluginReqs:
  285. templates = [
  286. """
  287. ``` {#tableForm_table plugin="tableForm"}
  288. # showInView: true # Add attribute to show the plugin in normal view
  289. groups:
  290. # - "*" # show all users who have some value on any of fields
  291. - Group Name # Use Group Name here
  292. fields:
  293. - d1=demo1 # List your fields here, = for alias
  294. table: true
  295. report: true
  296. openButtonText: Avaa taulukko # text for open the table if closed as default
  297. hideButtonText: Sulje taulukko # tex for closing the table
  298. open: true # use false if table is big and you do not want it open automatically
  299. autosave: true # save fields automatically
  300. maxRows: 40em # max height for the table before scrollbar
  301. realnames: true # Show full name in 2nd column, true or false
  302. usernames: false # Show user name column
  303. emails: false # Show email column
  304. addedDates: false # Show the date the user was added
  305. #buttonText: Tallenna # Name your save button here
  306. cbColumn: true # show checkboxes
  307. nrColumn: true # show numbers
  308. # maxRows: 40em # Hiw long is the table
  309. # maxCols: fit-content # width of the table
  310. # maxWidth: 30em # max for column
  311. filterRow: true # show filters
  312. singleLine: true # show every line as a single line
  313. emailUsersButtonText: "Lähetä sähköpostia valituille" # if one wants to send email
  314. separator: ";" # Define your value separator here, ";" as default
  315. anonNames: false # To show or hide user (and full) names in report, true or false
  316. reportButton: "Raportti"
  317. userListButtonText: "Käyttäjälista"
  318. showToolbar: true # toolbar for editing the table
  319. # hiddenColumns: [0,1] # which colums are hidden
  320. # forceUpdateButtonText: "Virkistä" # button for refreshing the table
  321. #dataView: # uncomment this if table is big or want to use special properties
  322. # tableWidth: 90vw
  323. # virtual:
  324. # enabled: true # toggles virtual mode on or off; default true
  325. # fixedColumns: 1 # how many not scrolling columns in left
  326. ```"""
  327. ]
  328. editor_tabs: list[EditorTab] = [
  329. {
  330. "text": "Fields",
  331. "items": [
  332. {
  333. "text": "Tables",
  334. "items": [
  335. {
  336. "data": templates[0].strip(),
  337. "text": "Table and report",
  338. "expl": "Form a table for editing forms, that can be converted to report",
  339. },
  340. ],
  341. },
  342. ],
  343. },
  344. ]
  345. return {
  346. "js": ["tableForm"],
  347. "multihtml": True,
  348. "editor_tabs": editor_tabs,
  349. }
  350. tableForm_plugin = create_blueprint(
  351. __name__,
  352. "tableForm",
  353. TableFormHtmlModel,
  354. TableFormAnswerModel,
  355. answer,
  356. reqs,
  357. csrf,
  358. )
  359. def check_field_filtering(
  360. r_filter: RegexOrComparator | None, target: str | float | None
  361. ) -> bool:
  362. if r_filter is None:
  363. return True
  364. return r_filter.is_match(target)
  365. @tableForm_plugin.get("/generateCSV")
  366. @use_args(GenerateCSVSchema())
  367. def gen_csv(args: GenerateCSVModel) -> Response | str:
  368. """
  369. Generates a report defined by tableForm attributes
  370. # TODO: generic, move
  371. :return: CSV containing headerrow and rows for users and values
  372. """
  373. curr_user = get_current_user_object()
  374. (
  375. docid,
  376. groups,
  377. separator,
  378. show_real_names,
  379. show_user_names,
  380. show_emails,
  381. remove_doc_ids,
  382. fields,
  383. user_filter,
  384. filter_fields,
  385. filter_values,
  386. ) = (
  387. args.docId,
  388. args.groups,
  389. args.separator,
  390. args.realnames,
  391. args.usernames,
  392. args.emails,
  393. args.removeDocIds,
  394. args.fields,
  395. args.userFilter,
  396. args.filterFields,
  397. args.filterValues,
  398. )
  399. if len(separator) > 1:
  400. # TODO: Add support >1 char strings like in Korppi
  401. return "Only 1-character string separators supported for now"
  402. doc = get_doc_or_abort(docid)
  403. if not isinstance(remove_doc_ids, bool):
  404. remove_doc_ids = True
  405. view_ctx = view_ctx_with_urlmacros(ViewRoute.Unknown)
  406. r = tableform_get_fields(
  407. fields,
  408. groups,
  409. doc,
  410. curr_user,
  411. view_ctx,
  412. remove_doc_ids,
  413. allow_non_teacher=True,
  414. user_filter=user_filter,
  415. # TODO: group_filter_type=self.markup.includeUsers,
  416. )
  417. data: list[list[str | float | None]] = [[]]
  418. if show_real_names:
  419. data[0].append("Real name")
  420. if show_user_names:
  421. data[0].append("Username")
  422. if show_emails:
  423. data[0].append("email")
  424. tmp: Sequence[str | float | None] = r["fields"]
  425. data[0] = data[0] + list(tmp)
  426. if len(filter_fields) != len(filter_values):
  427. raise RouteException("Filter targets and filter values do not match")
  428. # TODO: Check if filters could be easily integrated to original query in get_fields_and_users
  429. regs = {}
  430. # TODO: Create ComparatorFilters
  431. try:
  432. for i, f in enumerate(filter_fields):
  433. reg = RegexOrComparator(filter_values[i])
  434. if reg:
  435. regs[f] = reg
  436. except IndexError:
  437. raise RouteException("Too many filters")
  438. for rowkey, row in sorted(r["rows"].items()):
  439. row_data: list[str | float | None] = []
  440. u = r["users"].get(rowkey)
  441. if show_real_names:
  442. if u is None:
  443. continue
  444. val = u.get("real_name")
  445. row_data.append(val)
  446. if not check_field_filtering(regs.get("realname"), val):
  447. continue
  448. if show_user_names:
  449. val = rowkey
  450. row_data.append(val)
  451. if not check_field_filtering(regs.get("username"), val):
  452. continue
  453. if show_emails:
  454. if u is None:
  455. continue
  456. val = u.get("email")
  457. row_data.append(val)
  458. if not check_field_filtering(regs.get("email"), val):
  459. continue
  460. filter_this_row = False
  461. for i, _field in enumerate(r["fields"]):
  462. v = row.get(_field)
  463. row_data.append(v)
  464. if not check_field_filtering(regs.get(_field), v):
  465. filter_this_row = True
  466. break
  467. if filter_this_row:
  468. continue
  469. data.append(row_data)
  470. csv = csv_string(data, "excel", separator)
  471. output = ""
  472. if isinstance(args.reportFilter, str) and args.reportFilter:
  473. params = JsRunnerParams(code=args.reportFilter, data=csv)
  474. try:
  475. csv, output = jsrunner_run(params)
  476. except JsRunnerError as e:
  477. raise RouteException("Error in JavaScript: " + str(e)) from e
  478. return text_response(output + csv)
  479. """
  480. # This did not work because if code is just return data; then it is not identical when returned
  481. if args.reportFilter:
  482. params = {'code': args.reportFilter, 'data': data}
  483. data, output = jsrunner_run(params)
  484. return csv_response(data, 'excel', separator)
  485. """
  486. @dataclass
  487. class FetchTableDataModel:
  488. taskid: str
  489. @tableForm_plugin.get("/fetchTableData")
  490. @use_model(FetchTableDataModel)
  491. def fetch_rows(m: FetchTableDataModel) -> Response:
  492. curr_user = get_current_user_object()
  493. tid = TaskId.parse(m.taskid, require_doc_id=True, allow_block_hint=False)
  494. assert tid.doc_id is not None
  495. doc = get_doc_or_abort(tid.doc_id)
  496. doc.document.insert_preamble_pars()
  497. view_ctx = view_ctx_with_urlmacros(ViewRoute.Unknown)
  498. try:
  499. plug = find_plugin_from_document(
  500. doc.document, tid, UserContext.from_one_user(curr_user), view_ctx
  501. )
  502. except TaskNotFoundException:
  503. raise NotExist(f"Table not found: {tid}")
  504. markup = load_tableform_markup(plug)
  505. include_users = markup.includeUsers
  506. fields = markup.fields
  507. if not isinstance(fields, list):
  508. fields = []
  509. groups = markup.groups
  510. if not isinstance(groups, list):
  511. groups = []
  512. r = tableform_get_fields(
  513. fields,
  514. groups,
  515. doc,
  516. curr_user,
  517. view_ctx,
  518. value_or_default(markup.removeDocIds, True),
  519. value_or_default(markup.showInView, False),
  520. group_filter_type=include_users,
  521. )
  522. return json_response(r)
  523. def load_tableform_markup(plug: Plugin) -> TableFormMarkupModel:
  524. model: TableFormMarkupModel = TableFormMarkupSchema().load(plug.values)
  525. return model
  526. @dataclass
  527. class FetchTableDataModelPreview(FetchTableDataModel):
  528. fields: list[str]
  529. groups: list[str]
  530. removeDocIds: bool = True
  531. @tableForm_plugin.get("/fetchTableDataPreview")
  532. @use_model(FetchTableDataModelPreview)
  533. def fetch_rows_preview(m: FetchTableDataModelPreview) -> Response:
  534. curr_user = get_current_user_object()
  535. tid = TaskId.parse(m.taskid, require_doc_id=False, allow_block_hint=False)
  536. assert tid.doc_id is not None
  537. doc = get_doc_or_abort(tid.doc_id)
  538. doc.document.insert_preamble_pars()
  539. # With this route we can't be certain about showInView so we just check for edit access
  540. # whoever can open the plugin in preview should have that right
  541. if not curr_user.has_edit_access(doc):
  542. raise AccessDenied(f"Missing edit access for document {doc.id}")
  543. view_ctx = view_ctx_with_urlmacros(ViewRoute.Unknown)
  544. r = tableform_get_fields(
  545. m.fields,
  546. m.groups,
  547. doc,
  548. curr_user,
  549. view_ctx,
  550. m.removeDocIds,
  551. allow_non_teacher=True
  552. # TODO: group_filter_type = plug.values.get("includeUsers"),
  553. )
  554. return json_response(r)
  555. @dataclass
  556. class UpdateFieldsModel:
  557. taskid: str
  558. fields: list[str]
  559. @tableForm_plugin.get("/updateFields")
  560. @use_model(UpdateFieldsModel)
  561. def update_fields(m: UpdateFieldsModel) -> Response:
  562. r: dict[str, Any] = {}
  563. fields_to_update = m.fields
  564. taskid = m.taskid
  565. tid = TaskId.parse(taskid, require_doc_id=True, allow_block_hint=False)
  566. assert tid.doc_id is not None
  567. doc = get_doc_or_abort(tid.doc_id)
  568. curr_user = get_current_user_object()
  569. view_ctx = view_ctx_with_urlmacros(ViewRoute.Unknown)
  570. try:
  571. plug = find_plugin_from_document(
  572. doc.document, tid, UserContext.from_one_user(curr_user), view_ctx
  573. )
  574. except TaskNotFoundException:
  575. raise NotExist(f"Table not found: {tid}")
  576. markup = load_tableform_markup(plug)
  577. groupnames = markup.groups
  578. if not isinstance(groupnames, list):
  579. groupnames = []
  580. fielddata, _, field_names, _ = get_fields_and_users(
  581. fields_to_update,
  582. RequestedGroups.from_name_list(groupnames),
  583. doc,
  584. curr_user,
  585. view_ctx,
  586. value_or_default(markup.removeDocIds, True),
  587. add_missing_fields=True,
  588. access_option=GetFieldsAccess.from_bool(
  589. value_or_default(markup.showInView, False)
  590. ),
  591. )
  592. rows = {}
  593. styles = {}
  594. for f in fielddata:
  595. username = f["user"].name
  596. rows[username] = dict(f["fields"])
  597. for key, content in rows[username].items():
  598. if type(content) is dict:
  599. rows[username][key] = json.dumps(content)
  600. styles[username] = dict(f["styles"])
  601. r["rows"] = rows
  602. r["styles"] = styles
  603. r["fields"] = field_names
  604. return json_response(r)
  605. class TableFormUserInfo(TypedDict):
  606. id: int
  607. real_name: str
  608. email: str
  609. class TableFormObj(TypedDict):
  610. rows: dict[str, UserFields]
  611. users: dict[str, TableFormUserInfo]
  612. membership_add: dict[str, str | None]
  613. membership_end: dict[str, str | None]
  614. fields: list[str]
  615. aliases: dict[str, str]
  616. styles: dict[str, dict[str, str | None]]
  617. def tableform_get_fields(
  618. flds: list[str],
  619. groupnames: list[str],
  620. doc: DocInfo,
  621. curr_user: User,
  622. view_ctx: ViewContext,
  623. remove_doc_ids: bool,
  624. allow_non_teacher: bool,
  625. group_filter_type: MembershipFilter = MembershipFilter.Current,
  626. user_filter: list[str] | None = None,
  627. ) -> TableFormObj:
  628. fielddata, aliases, field_names, groups = get_fields_and_users(
  629. flds,
  630. RequestedGroups.from_name_list(groupnames),
  631. doc,
  632. curr_user,
  633. view_ctx,
  634. remove_doc_ids,
  635. add_missing_fields=True,
  636. access_option=GetFieldsAccess.from_bool(allow_non_teacher),
  637. member_filter_type=group_filter_type,
  638. user_filter=User.name.in_(user_filter) if user_filter else None,
  639. )
  640. rows = {}
  641. users: dict[str, TableFormUserInfo] = {}
  642. styles = {}
  643. group_ids = {g.id for g in groups} if groups else None
  644. membership_add_map: dict[str, str | None] = {}
  645. membership_end_map: dict[str, str | None] = {}
  646. for f in fielddata:
  647. u: User = f["user"]
  648. username = u.name
  649. rows[username] = dict(f["fields"])
  650. for key, content in rows[username].items():
  651. if type(content) is dict:
  652. rows[username][key] = json.dumps(content)
  653. rn = f["user"].real_name
  654. email = f["user"].email
  655. users[username] = TableFormUserInfo(
  656. id=u.id,
  657. real_name=rn if rn is not None else "",
  658. email=email if email is not None else "",
  659. )
  660. styles[username] = dict(f["styles"])
  661. if group_ids:
  662. if group_filter_type != MembershipFilter.Current:
  663. membership_end = get_membership_end(u, group_ids)
  664. membership_end_map[username] = (
  665. membership_end.astimezone(fin_timezone).strftime("%Y-%m-%d %H:%M")
  666. if membership_end
  667. else None
  668. )
  669. membership_added = get_membership_added(u, group_ids)
  670. membership_add_map[username] = (
  671. membership_added.astimezone(fin_timezone).strftime("%Y-%m-%d %H:%M")
  672. if membership_added
  673. else None
  674. )
  675. r = TableFormObj(
  676. rows=rows,
  677. users=users,
  678. membership_add=membership_add_map,
  679. membership_end=membership_end_map,
  680. fields=field_names,
  681. aliases=aliases,
  682. styles=styles,
  683. )
  684. return r