admin / Bid-Sentinel
publicBid Scrape and Tracking Application with AI Capability
Bid-Sentinel / bid-sentinel-v2 / make_user_guide.py
32879 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 | """Generates USER_GUIDE.pdf for the Bid Sentinel application.""" import os from reportlab.lib.colors import HexColor from reportlab.lib.enums import TA_CENTER, TA_LEFT from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.units import cm from reportlab.lib.utils import ImageReader from reportlab.platypus import ( HRFlowable, Image, ListFlowable, ListItem, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, ) LOGO_PATH = os.path.join(os.path.dirname(__file__), "assets", "bid-sentinel-logo.png") BRAND = HexColor("#0b3d91") BRAND_LIGHT = HexColor("#1d5fd6") GREY = HexColor("#475569") styles = getSampleStyleSheet() h_title = ParagraphStyle("h_title", parent=styles["Title"], textColor=BRAND, fontSize=26, spaceAfter=4) h_sub = ParagraphStyle("h_sub", parent=styles["Normal"], textColor=GREY, fontSize=11, spaceAfter=18) h1 = ParagraphStyle("h1", parent=styles["Heading1"], textColor=BRAND, fontSize=15, spaceBefore=14, spaceAfter=6) h2 = ParagraphStyle("h2", parent=styles["Heading2"], textColor=BRAND_LIGHT, fontSize=12, spaceBefore=8, spaceAfter=4) body = ParagraphStyle("body", parent=styles["Normal"], fontSize=10, leading=15, alignment=TA_LEFT, spaceAfter=6) small = ParagraphStyle("small", parent=styles["Normal"], fontSize=8.5, textColor=GREY) mono = ParagraphStyle("mono", parent=styles["Code"], fontSize=9, leading=13, backColor=HexColor("#f1f5f9"), borderPadding=6, leftIndent=4) def bullets(items): return ListFlowable( [ListItem(Paragraph(t, body), leftIndent=10, value="•") for t in items], bulletType="bullet", start="•", ) def info_table(rows, col_widths): t = Table(rows, colWidths=col_widths, hAlign="LEFT") t.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), BRAND), ("TEXTCOLOR", (0, 0), (-1, 0), HexColor("#ffffff")), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 9), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [HexColor("#ffffff"), HexColor("#f1f5f9")]), ("GRID", (0, 0), (-1, -1), 0.5, HexColor("#cbd5e1")), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("LEFTPADDING", (0, 0), (-1, -1), 7), ])) return t h_sub_center = ParagraphStyle("h_sub_center", parent=h_sub, alignment=TA_CENTER) story = [] # --- Cover --- story.append(Spacer(1, 0.6 * cm)) if os.path.exists(LOGO_PATH): iw, ih = ImageReader(LOGO_PATH).getSize() disp_w = 9 * cm disp_h = disp_w * ih / iw logo = Image(LOGO_PATH, width=disp_w, height=disp_h) logo.hAlign = "CENTER" story.append(logo) story.append(Spacer(1, 0.2 * cm)) else: story.append(Paragraph("BID SENTINEL", h_title)) story.append(Paragraph("User & Admin Guide", h_sub_center)) story.append(HRFlowable(width="100%", thickness=2, color=BRAND, spaceAfter=10)) story.append(Paragraph( "A dockerized platform to scrape, track, and manage UK public and private " "sector tender opportunities for Cyber Security Services (MDR, SOC, Incident " "Response, Vulnerability Management, CTI, and more).", body)) story.append(Spacer(1, 0.3 * cm)) # --- 1. Getting Started --- story.append(Paragraph("1. Getting Started", h1)) story.append(Paragraph("Prerequisites", h2)) story.append(bullets([ "Docker Desktop (or Docker Engine + Docker Compose v2) installed and running.", "Ports 8080 (web), 8000 (API), and 5432 (database) free on your machine.", ])) story.append(Paragraph("Launch the application", h2)) story.append(Paragraph( "From the project root, copy the environment template and start the stack:", body)) story.append(Paragraph( "cp .env.example .env<br/>docker compose up --build -d<br/>" "docker compose logs -f backend", mono)) story.append(Spacer(1, 0.2 * cm)) story.append(Paragraph("Then open the dashboard in your browser:", body)) story.append(info_table( [["Service", "Address"], ["Web Dashboard", "http://localhost:8080"], ["API documentation", "http://localhost:8000/docs"], ["Health check", "http://localhost:8000/health"]], [6 * cm, 9 * cm])) # --- 2. First-run setup & Logging In --- story.append(Paragraph("2. First-Run Setup & Logging In", h1)) story.append(Paragraph( "Bid Sentinel does not ship with a default account. The <b>first time</b> you " "open the dashboard, the login screen detects that no users exist and instead " "shows a <b>Create administrator account</b> form.", body)) story.append(Paragraph("Create your administrator", h2)) story.append(ListFlowable([ ListItem(Paragraph("Enter your full name, email address, and a password " "(minimum 8 characters), then confirm the password.", body), leftIndent=10), ListItem(Paragraph("Click <b>Create admin account</b>. You are signed in " "automatically as an administrator.", body), leftIndent=10), ListItem(Paragraph("The setup form locks itself permanently once this first " "account exists — afterwards the screen shows the normal " "<b>Sign in</b> form.", body), leftIndent=10), ], bulletType="1")) story.append(Spacer(1, 0.2 * cm)) story.append(bullets([ "Your session is stored securely in the browser using a JWT token, so you " "stay logged in across page refreshes and browser restarts (persistent session).", "Click <b>Sign out</b> in the top-right corner to end your session.", "Additional users (admin or standard) can be created later by an " "administrator — see section 9.", ])) # --- 3. The Dashboard --- story.append(Paragraph("3. Using the Dashboard", h1)) story.append(Paragraph( "The dashboard shows a customisable <b>Performance overview</b> at the top, " "followed by the full table of cyber-security opportunities.", body)) story.append(Paragraph( "<b>Light & dark mode.</b> A sun/moon button in the header (and on the " "sign-in screen) switches between light and dark themes. Your choice is " "remembered on that browser; first-time visitors follow their operating " "system's preference automatically.", body)) story.append(Paragraph("Performance overview (analytics)", h2)) story.append(Paragraph( "A strip of KPI cards and charts summarises bid performance and updates " "instantly as you change a bid status or outcome. Use the toggles to pivot " "the data:", body)) story.append(bullets([ "<b>KPI cards</b> — Total tracked, Open now, Bidding, Pipeline value, " "Closing in 14 days, <b>Win rate</b>, and <b>Value won</b>. Once an " "opportunity's outcome is <b>Lost</b> it drops out of the <b>Bidding</b> " "count and its amount is removed from <b>Pipeline value</b>, so those cards " "always reflect the live pipeline.", "<b>Measure</b> toggle — switch every chart between opportunity <b>Count</b> " "and total <b>Value (£)</b>.", "<b>Date</b> toggle — base the time chart on Published or Closing dates.", "<b>Range</b> toggle — 90 days, 12 months, or All time.", "<b>Pivot by</b> — break the data down by Portal, Keyword, Buyer, Bid status, " "or Outcome.", "Charts: bid pipeline by status, opportunities over time, the configurable " "pivot breakdown, and demand by service line. Use <b>Hide</b> to collapse the " "whole panel.", ])) story.append(Paragraph("Table columns", h2)) story.append(info_table( [["Column", "Description"], ["Opportunity Name", "Title, buyer, matched keyword, and detected certifications"], ["Fit", "Capability fit % (see Capability fit & certifications below)"], ["Published", "Date the notice was published"], ["Closing", "Submission deadline, with a 'days left' countdown"], ["Value (£)", "Contract value (numeric) or raw text for ranges/POA"], ["Duration", "Contract length or start/end dates"], ["Bid Status", "Interactive dropdown: Pending / Bid / No Bid"], ["Outcome", "Result dropdown: Awaiting / Won / Lost (drives win rate)"], ["Link", "Opens the original notice on the source portal"]], [4.5 * cm, 10.5 * cm])) story.append(Paragraph("Searching, filtering and sorting", h2)) story.append(bullets([ "<b>Search box</b> — filter by opportunity title or buyer name.", "<b>Status filter</b> — show only Pending, Bid, or No Bid opportunities.", "<b>Portal filter</b> — show opportunities from a single source portal only.", "<b>Sort</b> — order the table by Published date, Closing date, Value, or " "Title, using the Asc/Desc toggle to switch direction.", ])) story.append(Paragraph("Capability fit & certifications", h2)) story.append(Paragraph( "Bid Sentinel scores how well each opportunity matches your services. In " "<b>Settings → Capability profile & fit</b>, build a profile of the " "services you offer in two ways: <b>add terms manually</b>, and/or <b>upload a " "service-overview document</b> (PDF, DOCX or TXT) to <b>generate terms from its " "content</b> — then tick the ones to keep (use Select all / Clear to help). " "Generation is local keyword extraction by default, or AI when the AI " "enhancement is enabled.", body)) story.append(bullets([ "<b>Fit %</b> on each row is the share of your capability terms the " "opportunity mentions. Hover it to see exactly which terms matched, and sort " "the table by <b>Fit %</b> to surface the best matches first.", "<b>Certifications</b> required/mentioned in a notice (Cyber Essentials Plus, " "ISO 27001, CHECK, SC/DV clearance, and more) are detected automatically and " "shown as amber chips under the opportunity name.", "After changing your profile, click <b>Recompute fit & certs</b> to " "re-score existing opportunities (new ones are scored automatically).", ])) story.append(Paragraph( "This is deterministic and explainable: it matches the exact terms in your " "lists, so widen coverage by adding terms. Nothing is sent to any external " "service.", small)) story.append(Paragraph("AI enhancement (optional)", h2)) story.append(Paragraph( "For richer results you can switch on the optional AI enhancement in " "<b>Settings → AI enhancement</b> (administrators only). When on, a " "low-cost Claude model produces a concise <b>AI summary</b> (shown at the top " "of the expanded view), a reasoned <b>Fit %</b>, and more accurate " "certifications, clearances and technical requirements. It requires an API key " "to be configured; if none is set the toggle is disabled and Bid Sentinel stays " "fully deterministic and offline. After switching it on, click <b>Recompute</b> " "to apply it to existing opportunities (new ones are analysed automatically).", body)) story.append(Paragraph( "A slow-blinking status dot in the dashboard header shows the AI Assistant at a " "glance: <b>green</b> when it is active (enabled and a key configured), " "<b>red</b> when it is off.", body)) story.append(Paragraph("Recording decisions and outcomes", h2)) story.append(Paragraph( "Each row has two colour-coded dropdowns. The <b>Bid Status</b> column " "(Pending / Bid / No Bid) records your decision to pursue an opportunity. The " "<b>Outcome</b> column (Awaiting / Won / Lost / <b>No Bid</b> / <b>Disregarded</b>) " "records the result once a bid concludes — this feeds the Win rate and Value won " "figures. Setting the <b>Bid Status</b> to <b>No Bid</b> automatically defaults the " "<b>Outcome</b> to <b>No Bid</b> (you can still change it afterwards). Setting an " "outcome to <b>Disregarded</b> marks an opportunity to hide; tick <b>Hide " "disregarded</b> in the toolbar to remove them from the view and the analytics. " "Both save instantly; if a save fails the dropdown reverts.", body)) story.append(Paragraph("Adding an opportunity manually", h2)) story.append(Paragraph( "To track an opportunity Bid Sentinel didn't scrape, click <b>+ Add " "opportunity</b> in the toolbar. Enter at least a <b>Title</b> (buyer, value, " "dates, duration, URL and description are optional) and click <b>Add " "opportunity</b>. It is analysed for certifications, fit and technical " "requirements just like a scraped one (paste the details into the Description " "to get the most out of this), and is labelled <b>Manual entry</b> — you can " "filter to these via the portal filter. A URL is optional; leave it blank and " "the Link column simply shows “Manual”.", body)) story.append(Paragraph("Editing or deleting an opportunity", h2)) story.append(Paragraph( "Expand any opportunity (the arrow beside its name) to reveal <b>Edit</b> and " "<b>Delete</b> buttons. <b>Edit</b> opens the same form pre-filled so you can " "correct any detail — certifications, fit and technical requirements are " "re-analysed when you save. <b>Delete</b> removes the opportunity entirely; " "use this to clear entries that were scraped in error. Deleting shows an " "<b>Undo</b> button in the notice bar — click it to restore the opportunity if " "you removed it by mistake (see “Undo a deletion” below). Both " "actions are recorded in the audit trail.", body)) story.append(Paragraph("Bulk-deleting unwanted opportunities", h2)) story.append(Paragraph( "Admins see a <b>checkbox</b> on each row. Tick several (or use the header " "checkbox to select every row in the current view), then click <b>🗑 " "Delete selected</b> in the toolbar. The chosen opportunities are removed and " "— crucially — <b>blocked from being scraped back in</b>: each one's link and " "reference are added to a suppression list the scraper checks on every run, so " "they will not reappear. Use the search and filters first to narrow the view, " "then select-all to clear a whole category quickly. The action is confirmed " "before it runs and is recorded in the audit trail.", body)) story.append(Paragraph("Undo a deletion", h2)) story.append(Paragraph( "Deleted something by mistake? After any delete — single or bulk — an " "<b>↵ Undo</b> button appears next to the confirmation message. Click it " "to restore the opportunities and, for bulk deletes, automatically lift the " "scrape block that was applied. Undo stays available for <b>14 days</b> after a " "deletion (comments are not restored). If the same notice has already been " "re-scraped in the meantime, that one is skipped.", body)) story.append(Paragraph("Tuning out irrelevant types", h2)) story.append(Paragraph( "Some scrapes surface opportunities that are not really Cyber Security — for " "example catering, grounds maintenance or printer supplies pulled in by a broad " "CPV code. Each row has a <b>🔕 Tune out</b> button (admins). Click it and " "confirm a word or phrase that characterises the type you want gone (a sensible " "suggestion is pre-filled). Bid Sentinel then removes the matching opportunities " "immediately and suppresses that type on all future scrapes.", body)) story.append(Paragraph( "<b>Guardrail:</b> anything Cyber Security related is <b>always kept</b>, no " "matter what you tune out — a term only hides opportunities that also have no " "cyber-security signal, so you can never accidentally lose a relevant bid. " "Manage or remove tune-out terms under <b>Settings → Tune-out list</b> " "(removing a term lets that type back in on the next scrape). Tune-out changes " "are recorded in the audit trail.", body)) story.append(Paragraph("Expanding an opportunity", h2)) story.append(Paragraph( "Click the <b>▸</b> arrow beside any opportunity name to expand it. The " "detail view shows three panes:", body)) story.append(bullets([ "<b>Certifications, accreditations & security clearances</b> required or " "mentioned in the notice (clearances such as SC/DV are listed separately).", "<b>Top technical requirements</b> — the most relevant requirement sentences " "scraped from the opportunity description.", "<b>Comments</b> — add notes for your team; each comment shows its author and " "timestamp, and can be deleted by its author or an administrator.", ])) # --- 4. Exporting --- story.append(Paragraph("4. Exporting Data", h1)) story.append(Paragraph( "Use the toolbar buttons above the table to export the <b>currently filtered " "view</b>:", body)) story.append(bullets([ "<b>Export CSV</b> — downloads a spreadsheet-ready file (opens in Excel / Google Sheets).", "<b>Export PDF</b> — downloads a formatted, landscape PDF report of the table.", ])) story.append(Paragraph( "Tip: apply a search term or status filter first to export just that subset.", small)) story.append(Paragraph("Management summary report", h2)) story.append(Paragraph( "Admins and analysts see a <b>📊 Management PDF</b> button in the toolbar " "(and on the Intelligence page). It downloads a concise, board-ready summary of " "the pipeline: how many opportunities are <b>tracked, open and being bid on</b>, " "the total <b>pipeline value</b> and <b>value won</b>, a breakdown by bid status, " "the opportunities currently being bid on, and your <b>capability</b> and " "<b>certification / clearance gaps</b>. Pipeline value and the being-bid-on count " "exclude anything marked <b>Lost</b>, so the figures match the dashboard. Where " "email is configured, an <b>Email summary</b> button sends it to your own " "account address.", body)) story.append(Paragraph("Email me this", h2)) story.append(Paragraph( "If outgoing email is configured, <b>Email CSV</b> / <b>Email PDF</b> buttons " "appear alongside the exports (and an <b>Email me</b> button on the Intelligence " "report). These send the report to <b>your own account email address</b> — the " "one you signed in with. If you don't see the buttons, email hasn't been set up " "on the server (see the Email setup guide).", body)) # --- 5. Settings: Bid Portals --- story.append(Paragraph("5. Settings — Bid Portals", h1)) story.append(Paragraph( "Open <b>Settings</b> from the top-right of the dashboard. The Settings page is " "organised into <b>tabs</b> — Users & roles, AI enhancement, Capability " "& fit, Bid Portals, Keywords, CPV codes, Tune-out list, Schedule, and Audit " "trail (the Users and Audit tabs are visible to administrators only). Click a tab to open that " "section; only that section is shown, so there is no long page to scroll.", body)) story.append(Paragraph( "On the <b>Bid Portals</b> tab you'll find every portal Bid Sentinel can check. " "<b>All portals are selected by default and have an active scraper</b> — switch " "any toggle off to exclude it from future scans (admins only). Find a Tender and " "Contracts Finder use official data APIs (robust); the others use a best-effort " "web scraper that runs every cycle but may need tuning per site (and " "credentials for portals that require a login) to return results reliably.", body)) story.append(Paragraph( "<b>Adding your own portal:</b> use the <b>Add a portal</b> form at the bottom " "of the panel (name, URL, and optional scope). It is registered immediately, " "marked <b>Custom</b>, and can be enabled, scheduled, and later given a " "scraper. Custom portals can be deleted; built-in ones can only be disabled " "with the toggle.", body)) story.append(info_table( [["Portal", "Scope"], ["Find a Tender Service (FTS) [API]", "Above-threshold UK public sector procurement"], ["Contracts Finder [API]", "Below-threshold England + wider public sector"], ["Sell2Wales", "Welsh public bodies"], ["Public Contracts Scotland", "Scottish public sector tenders"], ["eTendersNI", "Northern Ireland eProcurement"], ["ProContract (Due North)", "Regional councils and local authorities"], ["Delta eSourcing", "Various contracting authorities"], ["In-Tend", "Public sector localized portal instances"], ["Achilles", "Utilities, transport and infrastructure"], ["CCS / Government Commercial Agency", "Central government frameworks"], ["Digital Marketplace (G-Cloud)", "Cloud and digital services frameworks"]], [7.5 * cm, 7.5 * cm])) # --- 6. Settings: Keywords --- story.append(Paragraph("6. Settings — Keywords", h1)) story.append(Paragraph( "The <b>Keywords</b> panel shows the built-in cyber-security terms Bid " "Sentinel always searches (MDR, SOC, Incident Response, Vulnerability " "Management, CTI, and more). These cannot be removed.", body)) story.append(Paragraph( "Adding your own keywords is <b>optional</b>. Type a term (for example " "“Penetration Testing” or “ISO 27001”) and click " "<b>Add</b>; it is then included in every subsequent scan alongside the " "built-in list. Remove a custom keyword at any time with the × button.", body)) story.append(Paragraph("CPV codes", h2)) story.append(Paragraph( "The <b>CPV codes</b> tab lets you add Common Procurement Vocabulary codes " "(the standard EU/UK classification for what a contract is about) to <b>narrow</b> " "the scrape. Enter a <b>code</b> (for example 72500000) and an optional " "<b>description</b> (for example “Computer-related services”) and " "click <b>Add</b>.", body)) story.append(Paragraph( "CPV codes work as a <b>combined filter with your keywords, not an alternative " "to them</b>. When one or more CPV codes are <b>active</b>, an opportunity is " "only kept if it matches a <b>keyword</b> AND one of your active <b>CPV codes</b> " "(in its classification or its text). This removes notices that happen to carry " "a matching CPV code but are not actually about cyber security. If no CPV codes " "are active, keyword matching alone applies, exactly as before.", body)) story.append(Paragraph( "Each code has an <b>Active</b> toggle. Switch a code off to stop it applying to " "the scrape without deleting it — handy for temporarily widening or narrowing " "coverage. The header shows how many of your codes are currently active. Use " "<b>Remove</b> to delete a code permanently.", body)) story.append(Paragraph( "<b>CPV matching mode.</b> A toggle at the top of the tab controls how strictly " "CPV codes are applied:", body)) story.append(bullets([ "<b>Lenient (recommended, default)</b> — CPV codes only narrow notices that " "actually publish a CPV classification. Notices from sources that don't expose " "CPV codes (most HTML portals) are judged on keywords alone, so you don't lose " "relevant opportunities just because a portal omits CPV data.", "<b>Strict</b> — every opportunity must match one of your CPV codes. Anything " "without a matching CPV code — including notices that publish no CPV data at " "all — is excluded. Use this only when you want the tightest possible targeting.", ])) story.append(Paragraph( "If you added CPV codes and noticed fewer opportunities than expected, keep the " "mode on <b>Lenient</b> (or switch a code off).", small)) story.append(Paragraph( "The check-digit suffix is optional — 72500000 and 72500000-0 are treated the " "same. Remove a code at any time with the <b>Remove</b> button. Adding or " "removing CPV codes is an admin action and is recorded in the audit trail.", body)) story.append(Paragraph("Tune-out list", h2)) story.append(Paragraph( "The <b>Tune-out list</b> tab manages the negative terms that hide irrelevant " "opportunity types (see “Tuning out irrelevant types” above). It lists " "every tuned-out term; each can be removed to let that type back in on the next " "scrape. You can also add a term here directly. A built-in <b>guardrail</b> means " "a term only ever hides opportunities that have <b>no</b> cyber-security signal, so " "Cyber Security related opportunities are never suppressed.", body)) # --- 7. Settings: Scheduling --- story.append(Paragraph("7. Settings — Scheduling Scrapes", h1)) story.append(Paragraph( "The <b>Scrape Schedule</b> panel lets you run Bid Sentinel automatically on " "<b>multiple days and times</b>. To create a schedule:", body)) story.append(ListFlowable([ ListItem(Paragraph("Select one or more <b>days</b> (Mon–Sun).", body), leftIndent=10), ListItem(Paragraph("Add one or more <b>times</b> with the time pickers " "(use ‘+ Add time’ for more).", body), leftIndent=10), ListItem(Paragraph("Click <b>Add to schedule</b>. A slot is created for every " "day × time combination.", body), leftIndent=10), ], bulletType="1")) story.append(Paragraph( "Example: choosing Mon & Wed at 09:00 and 17:00 creates four slots. Each " "slot can be paused/resumed or deleted individually. The panel shows the " "<b>next scheduled run</b> and the timezone in use (default Europe/London). " "If no slots are set, Bid Sentinel falls back to a regular interval scan.", body)) # --- 8. The Scraper --- story.append(Paragraph("8. The Scraper Engine", h1)) story.append(Paragraph( "A background worker automatically scans your <b>enabled portals</b> on the " "<b>schedule you define</b> (or a regular interval if none is set), keeping " "only opportunities that match the built-in and custom keyword list — and, when " "you have set CPV codes, that ALSO match one of those codes. Duplicate notices " "are detected by URL and reference ID and never inserted twice.", body)) story.append(Paragraph( "<b>Only live opportunities are brought in.</b> At scrape time the engine ignores " "<b>contract award notices</b> and <b>closed, cancelled or withdrawn</b> tenders, " "and will not import an opportunity whose <b>closing date has already passed</b> — " "so nothing that is already closed is ever added. (Opportunities that publish no " "closing date are kept, since there is no date to judge them on.)", body)) story.append(Paragraph( "This filtering applies only when <b>importing new</b> opportunities. An " "opportunity that was scraped while it was open <b>stays in your table after its " "closing date passes</b> — it is never removed automatically — so your history " "and any bids you tracked against it are preserved. Remove old entries yourself " "with Delete or the multi-select bulk delete whenever you choose.", body)) story.append(Paragraph("Tracked keywords", h2)) story.append(Paragraph( "The built-in list is broad and cyber-specific — e.g. Cyber Security, " "Information Security, Managed Detection and Response, MDR, SOC, Security " "Operations Centre, Incident Response, Vulnerability Management / Assessment, " "Cyber Threat Intelligence (CTI), Penetration Testing, Digital Forensics, " "MSSP, SIEM, ISO 27001 and more. Add organisation-specific terms on the " "Keywords tab to widen coverage further.", body)) story.append(Paragraph("Running it on demand (admins only)", h2)) story.append(Paragraph( "Administrators see a <b>Run Scraper</b> button in the toolbar. Click it to " "fetch new opportunities immediately. The status message is a full <b>diagnostic " "summary</b>: how many candidates each portal returned, how many were newly " "inserted, and a breakdown of why the rest were skipped — already tracked, " "closed/expired, CPV-filtered, tuned-out or suppressed — plus whether the CPV filter is running " "in lenient or strict mode. If a portal shows <b>0</b> candidates or an error, " "that portal returned nothing (some HTML portals need per-site tuning or a " "login); if lots were CPV-filtered, switch the CPV mode to Lenient or disable " "some CPV codes.", body)) # --- 9. User Administration --- story.append(Paragraph("9. User Administration (Admins)", h1)) story.append(Paragraph( "Open <b>Settings</b> and use the <b>Users & roles</b> panel (visible to " "administrators only) to manage accounts with role-based access control:", body)) story.append(bullets([ "<b>Add a new user</b> — enter a full name, email, password (min 8 " "characters) and a role, then click <b>Add user</b>.", "<b>Change a role</b> — switch any user between Standard and Admin from the " "role dropdown.", "<b>Enable / disable</b> — click the status pill to suspend or restore " "access without deleting the account.", "<b>Reset password</b> — set a new password for any user inline.", "<b>Delete</b> — remove an account. You cannot delete or change the role of " "your own account (a safeguard against locking yourself out).", ])) story.append(Paragraph("Roles", h2)) story.append(info_table( [["Role", "Capabilities"], ["Admin", "Everything: manage users, portals, keywords, schedule, run scraper"], ["Analyst", "Read-only, plus the Intelligence tab and its PDF report"], ["Standard", "View tenders, search/filter/sort, set bid status & outcome, export"]], [4.5 * cm, 10.5 * cm])) story.append(Paragraph("Audit trail", h2)) story.append(Paragraph( "The <b>Audit trail</b> panel in Settings (administrators only) records " "application activity — logins and every change — with the actor, action, " "timestamp, result, and IP address. Use <b>Refresh</b> to load the latest " "entries; the most recent appear first.", body)) # --- 10. Analyst Intelligence --- story.append(Paragraph("10. Analyst Intelligence", h1)) story.append(Paragraph( "Users with the <b>Analyst</b> (or Admin) role see an <b>Intelligence</b> link " "in the dashboard header. The Intelligence tab mines the opportunity " "assessments to surface gaps and sales angles:", body)) story.append(bullets([ "<b>Capability gaps</b> — in-demand services appearing across opportunities " "that your capability profile doesn't cover, ranked by frequency and value.", "<b>Certification & clearance gaps</b> — how often each is required versus " "whether you hold it (from the <b>Accreditations you hold</b> list in Settings " "→ Capability & fit). Flags certs that block you.", "<b>Buyer intelligence</b> — a profile per client organisation (volume, value, " "average fit, win/loss, most-requested services) and which are prime targets.", "<b>Win/loss</b> and <b>demand trends</b> — where you win or lose, and how " "demand is moving over time.", "<b>Sales angles</b> — a concise, data-driven pitch for each target buyer; " "tick <b>AI angles</b> for an AI-written version (requires the AI enhancement).", ])) story.append(Paragraph( "Click <b>Export PDF report</b> to download a branded, printable Intelligence " "Report covering all of the above for sharing with the wider team.", body)) # --- 11. Troubleshooting --- story.append(Paragraph("11. Troubleshooting", h1)) story.append(info_table( [["Symptom", "Resolution"], ["Cannot reach dashboard", "Check 'docker compose ps'; ensure containers are 'Up'."], ["Login fails", "Verify ADMIN_EMAIL/PASSWORD in .env; data persists across restarts."], ["Table is empty", "Click 'Run Scraper' (admin) or wait for the scheduled run."], ["Reset everything", "Run 'docker compose down -v' then 'up --build -d'."]], [5.5 * cm, 9.5 * cm])) story.append(Spacer(1, 0.6 * cm)) story.append(HRFlowable(width="100%", thickness=1, color=HexColor("#cbd5e1"), spaceAfter=6)) story.append(Paragraph( "Bid Sentinel — UK Cyber Security Tender Platform. User Guide. Always respect " "the terms of use and rate limits of the source portals when scraping.", small)) def _footer(canvas, doc): canvas.saveState() canvas.setFont("Helvetica", 8) canvas.setFillColor(GREY) canvas.drawString(2 * cm, 1.1 * cm, "Bid Sentinel") canvas.drawRightString(A4[0] - 2 * cm, 1.1 * cm, f"Page {doc.page}") canvas.restoreState() _DOCS_DIR = os.path.join(os.path.dirname(__file__), "docs") os.makedirs(_DOCS_DIR, exist_ok=True) doc = SimpleDocTemplate( os.path.join(_DOCS_DIR, "USER_GUIDE.pdf"), pagesize=A4, leftMargin=2 * cm, rightMargin=2 * cm, topMargin=1.8 * cm, bottomMargin=1.8 * cm, title="Bid Sentinel - User Guide", author="Bid Sentinel", ) doc.build(story, onFirstPage=_footer, onLaterPages=_footer) print("USER_GUIDE.pdf generated.") |