Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • devel
  • fo
  • fo-base
  • honza/add-contestant
  • honza/kolo-vs-soutez
  • honza/mr6
  • honza/mr7
  • honza/mra
  • honza/mrd
  • honza/mrf
  • honza/submit-images
  • jh-stress-test-wip
  • jirka/typing
  • jk/issue-196
  • jk/issue-96
  • master
  • mj/submit-images
  • shorten-schools
18 results

Target

Select target project
  • mj/mo-submit
1 result
Select Git revision
  • devel
  • fo
  • fo-base
  • honza/add-contestant
  • honza/kolo-vs-soutez
  • honza/mr6
  • honza/mr7
  • honza/mra
  • honza/mrd
  • honza/mrf
  • honza/submit-images
  • jh-stress-test-wip
  • jirka/typing
  • jk/issue-196
  • jk/issue-96
  • master
  • mj/submit-images
  • shorten-schools
18 results
Show changes
File added
\input ltluatex.tex
\input luatex85.sty
\input ucwmac2.tex
\setmargins{15mm}
\setuppage
\nopagenumbers
\ucwmodule{luaofs}
\settextsize{12}
\baselineskip=18pt
\uselanguage{czech}
\frenchspacing
\newbox\logobox
\setbox\logobox=\putimage{width 21mm}{mo-logo.epdf}
\input qrcode.tex
\qrset{height=23mm, level=H, tight, silent}
\newbox\codebox
\def\kolo{TODO}
\def\kat{TODO}
\newbox\ellipsisbox
\setbox\ellipsisbox=\hbox{\bf~\dots~~}
\directlua{
function cut_box(box_nr, max_w)
local box = tex.box[box_nr]
% nodetree.analyze(box)
local n
local total_w = 0
local last_visible
for n in node.traverse(box.head) do
local w, h, d = node.dimensions(n, n.next)
total_w = total_w + w
if total_w > max_w then
local new = node.copy_list(box.head, last_visible.next)
tex.box[box_nr] = node.hpack(new)
% nodetree.analyze(tex.box[box_nr])
return
end
if n.id == 0 or n.id == 2 or n.id == 29 then % hlist, rule, glyph
last_visible = n
end
end
end
}
\def\limitedbox#1#2{%
\setbox0=\hbox{#2}%
\ifdim \wd0 > #1\relax
\dimen0=\dimexpr #1 - \wd\ellipsisbox\relax
\directlua{cut_box(0, tex.dimen[0])}%
\setbox0=\hbox{\box0\copy\ellipsisbox}%
\fi
\box0
}
\def\field#1#2{\hbox to #1{\limitedbox{#1}{#2}\hss}}
\def\fillin#1{\smash{\lower 2pt\hbox to #1{\hrulefill}}}
% \proto{kód}{jméno}{třída}{škola}{příklad}
\def\proto#1#2#3#4#5{
\setbox\codebox=\hbox{\qrcode{#1}}
\line{%
\vhang{\copy\logobox}%
\qquad
\vhanglines{\baselineskip=14pt\vskip -5pt\hbox{\bf\kolo}\hbox{\bf\kat}}%
\hfil
\smash{\vhang{\box\codebox}}%
}
\medskip
\prevdepth=0pt
\leftline{%
\field{0.63\hsize}{Jméno: {\bf #2}}%
Třída: {\bf #3}%
}
\leftline{%
\field{0.63\hsize}{Škola: {\bf #4}}%
Příklad: {\bf #5}%
}
\leftline{%
\field{0.3\hsize}{List {\bf 1} ze \fillin{10mm}}%
\field{0.33\hsize}{Hodnotil:}%
Bodů:
}
\bigskip
\hrule
\vfill\eject
}
\def\universal{\proto{MO:*}{}{}{}{}}
\def\blank{%
\setbox\codebox=\hbox{\qrcode[height=15mm]{MO:+}}
\line{%
\field{0.63\hsize}{Jméno:}%
\field{0.2\hsize}{Třída:}%
\hss
\raise\ht\strutbox\hbox{\smash{\vhang{\box\codebox}}}
}
\leftline{%
\field{0.63\hsize}{List \fillin{10mm} ze \fillin{10mm}}%
\field{0.2\hsize}{Příklad:}%
}
\bigskip
\nointerlineskip
\hbox to 0.85\hsize{\hrulefill}
\vfill\eject
}
This diff is collapsed.
\input protokol.tex
\def\kolo{Krajské kolo 70. ročníku Matematické olympiády}
\def\kat{Kategorie P, Zlínský kraj}
\proto{MO:70-P-III-1:12345}{Pokusný Králík}{4/4}{Gymnázium Na Paloučku, Králíky}{P-III-1}
\proto{MO:70-P-III-2:12345}{Pokusný Králík}{4/4}{Gymnázium Na Paloučku, Králíky}{P-III-2}
\proto{MO:70-P-III-3:12345}{Pokusný Králík}{4/4}{Gymnázium Na Paloučku, Králíky}{P-III-3}
\proto{MO:70-P-III-4:12345}{Pokusný Králík}{4/4}{Gymnázium Na Paloučku, Králíky}{P-III-4}
\proto{MO:70-P-III-4:12345}{Pokusný Králík}{4/4}{MŠ, ZŠ a SŠ pro sluchově postižené, Valašské Meziříčí}{P-III-4}
\universal
\blank
\bye
\ucwdefmodule{luaofs}
\ucwdefmodule{ofs}
\input luaotfload.sty
\input ofs.tex
\nofontmessages
\ofsputfamlist{^^JLatinModern:}
\def\LMfeat#1{:mode=base;script=latn;+tlig}
%%% LMRoman %%%
\ofsdeclarefamily [LMRoman] {%
\loadtextfam lmr;%
lmbx;%
lmti;%
lmbxti;;%
\newvariant 8 \sl (Slanted) lmsl;;%
\newvariant 9 \bxsl (BoldSlanted) lmbxsl;;%
\newvariant a \bo (BoldNormal) lmb;;%
\newvariant b \bosl (BoldNormalSlanted) lmbsl;;%
\newvariant c \csc (CapsAndSmallCaps) lmcsc;;%
}
\registertfm lmr - LMRoman10-Regular\LMfeat{}
\registertfm lmr 0pt-6pt LMRoman5-Regular\LMfeat{}
\registertfm lmr 6pt-7pt LMRoman6-Regular\LMfeat{}
\registertfm lmr 7pt-8pt LMRoman7-Regular\LMfeat{}
\registertfm lmr 8pt-9pt LMRoman8-Regular\LMfeat{}
\registertfm lmr 9pt-10pt LMRoman9-Regular\LMfeat{}
\registertfm lmr 10pt-12pt LMRoman10-Regular\LMfeat{}
\registertfm lmr 12pt-17pt LMRoman12-Regular\LMfeat{}
\registertfm lmr 17pt-* LMRoman17-Regular\LMfeat{}
\registertfm lmbx - LMRoman10-Bold\LMfeat{}
\registertfm lmbx 0pt-6pt LMRoman5-Bold\LMfeat{}
\registertfm lmbx 6pt-7pt LMRoman6-Bold\LMfeat{}
\registertfm lmbx 7pt-8pt LMRoman7-Bold\LMfeat{}
\registertfm lmbx 8pt-9pt LMRoman8-Bold\LMfeat{}
\registertfm lmbx 9pt-10pt LMRoman9-Bold\LMfeat{}
\registertfm lmbx 10pt-12pt LMRoman10-Bold\LMfeat{}
\registertfm lmbx 12pt-* LMRoman12-Bold\LMfeat{}
\registertfm lmti - LMRoman10-Italic\LMfeat{}
\registertfm lmti 0pt-8pt LMRoman7-Italic\LMfeat{}
\registertfm lmti 8pt-9pt LMRoman8-Italic\LMfeat{}
\registertfm lmti 9pt-10pt LMRoman9-Italic\LMfeat{}
\registertfm lmti 10pt-12pt LMRoman10-Italic\LMfeat{}
\registertfm lmti 12pt-* LMRoman12-Italic\LMfeat{}
\registertfm lmbxti - LMRoman10-BoldItalic\LMfeat{}
\registertfm lmsl - LMRomanSlant10-Regular\LMfeat{}
\registertfm lmsl 0pt-9pt LMRomanSlant8-Regular\LMfeat{}
\registertfm lmsl 9pt-10pt LMRomanSlant9-Regular\LMfeat{}
\registertfm lmsl 10pt-12pt LMRomanSlant10-Regular\LMfeat{}
\registertfm lmsl 12pt-17pt LMRomanSlant12-Regular\LMfeat{}
\registertfm lmsl 17pt-* LMRomanSlant17-Regular\LMfeat{}
\registertfm lmbxsl - LMRomanSlant10-Bold\LMfeat{}
\registertfm lmb - LMRomanDemi10-Regular\LMfeat{}
\registertfm lmbsl - LMRomanDemi10-Oblique\LMfeat{}
\registertfm lmcsc - LMRomanCaps10-Regular\LMfeat{}
\setfonts[LMRoman/]
%%% LMSans %%%
\ofsdeclarefamily [LMSans] {%
\loadtextfam lmss;%
lmssbx;%
lmsso;%
lmssbo;;%
}
\registertfm lmss - LMSans10-Regular\LMfeat{}
\registertfm lmss 0pt-9pt LMSans8-Regular\LMfeat{}
\registertfm lmss 9pt-10pt LMSans9-Regular\LMfeat{}
\registertfm lmss 10pt-12pt LMSans10-Regular\LMfeat{}
\registertfm lmss 12pt-17pt LMSans12-Regular\LMfeat{}
\registertfm lmss 17pt-* LMSans17-Regular\LMfeat{}
\registertfm lmssbx - LMSans10-Bold\LMfeat{}
\registertfm lmsso - LMSans10-Oblique\LMfeat{}
\registertfm lmsso 0pt-9pt LMSans8-Oblique\LMfeat{}
\registertfm lmsso 9pt-10pt LMSans9-Oblique\LMfeat{}
\registertfm lmsso 10pt-12pt LMSans10-Oblique\LMfeat{}
\registertfm lmsso 12pt-17pt LMSans12-Oblique\LMfeat{}
\registertfm lmsso 17pt-* LMSans17-Oblique\LMfeat{}
\registertfm lmssbo - LMSans10-BoldOblique\LMfeat{}
%%% LMSansDC %%%
\ofsdeclarefamily [LMSansDC] {%
\loadtextfam lmssdc;%
;%
lmssdo;%
;;%
}
\registertfm lmssdc - LMSansDemiCond10-Regular\LMfeat{}
\registertfm lmssdo - LMSansDemiCond10-Oblique\LMfeat{}
%%% LMMono %%%
\ofsdeclarefamily [LMMono] {%
\loadtextfam lmtt;%
lmtk;%
lmtti;%
;;%
\newvariant 8 \sl (Slanted) lmtto;;%
\newvariant 9 \bxsl (BoldSlanted) lmtko;;%
\newvariant c \csc (CapsAndSmallCaps) lmtcsc;;%
}
\registertfm lmtt - LMMono10-Regular\LMfeat{}
\registertfm lmtt 0pt-9pt LMMono8-Regular\LMfeat{}
\registertfm lmtt 9pt-10pt LMMono9-Regular\LMfeat{}
\registertfm lmtt 10pt-12pt LMMono10-Regular\LMfeat{}
\registertfm lmtt 12pt-* LMMono12-Regular\LMfeat{}
\registertfm lmtk - LMMonoLt10-Bold\LMfeat{}
\registertfm lmtti - LMMono10-Italic\LMfeat{}
\registertfm lmtto - LMMonoSlant10-Regular\LMfeat{}
\registertfm lmtko - LMMonoLt10-BoldOblique\LMfeat{}
\registertfm lmtcsc - LMMonoCaps10-Regular\LMfeat{}
\newfam\ttfam
\loadmathfam\ttfam[/LMMono10-Regular\LMfeat{}]
%%% LMMonoCondensed %%%
\ofsdeclarefamily [LMMonoCondensed] {%
\loadtextfam lmtlc;%
;%
lmtlco;%
;;%
}
\registertfm lmtlc - LMMonoLtCond10-Regular\LMfeat{}
\registertfm lmtlco - LMMonoLtCond10-Oblique\LMfeat{}
%%% UCW extensions %%%
\ofsputfamlist{^^JUCW:}
\ofsdeclarefamily [BlackboardBold] {%
\loadtextfam bbm;%
;%
;%
;\defaultextraenc;%
}
\registertfm bbm - bbm10
\registertfm bbm 0pt-6pt bbm5
\registertfm bbm 6pt-7pt bbm6
\registertfm bbm 7pt-8pt bbm7
\registertfm bbm 8pt-9pt bbm8
\registertfm bbm 9pt-10pt bbm9
\registertfm bbm 10pt-12pt bbm10
\registertfm bbm 12pt-17pt bbm12
\registertfm bbm 17pt-* bbm17
% \bb - blackboard bold math font
\newfam\bbfam
\def\bb{\fam\bbfam}
\def\loadbbm{%
\loadmathfam\bbfam[/bbm]%
}
\loadbbm
%%% Font size switches %%%
% Recalculate line spacing for a given point size of the font (assuming CM-like metrics).
% Also sets \strut and \topskip.
\def\setbaselines#1{%
\dimen0=1pt
\dimen0=#1\dimen0
\normalbaselineskip=1.2\dimen0
\normallineskip=0.1\dimen0
\setbox\strutbox=\hbox{\vrule height 0.85\dimen0 depth 0.35\dimen0 width 0pt}%
\topskip=1\dimen0
\normalbaselines
}
% Switch to a specified font size (including math, line spacing etc.)
\def\settextsize#1{%
\def\fomenc{CM}%
\setfonts[/#1]%
\setmath[//]%
\setbaselines{#1}%
\loadbbm
}
\def\twelvepoint{\settextsize{12}}
%%% Various hacks %%%
% Re-define \tt, so that it works in both text and math mode
\def\tt{\ifmmode\fam\ttfam\else\setfonts[LMMono/]\fi}
% Re-define ucwmac's \fontfont
\let\footfont=\tenrm
% The UCW Macro Collection (a successor of mjmac.tex)
% Written by Martin Mares <mj@ucw.cz> in 2010--2018 and placed into public domain
% -------------------------------------------------------------------------------
\ifx\ucwmodule\undefined\else\endinput\fi
%%% Prolog %%%
% We'll use internal macros of plain TeX
\catcode`@=11
\ifx\eTeXversion\undefined
\errmessage{ucwmac requires the e-TeX engine or its successor}
\fi
%%% PDF output detection %%%
\newif\ifpdf
\pdffalse
\ifx\pdfoutput\undefined
\else\ifnum\pdfoutput>0
\pdftrue
\pdfpkresolution=600 % Provide a reasonable default
\fi\fi
\ifx\luatexversion\undefined\else
% In LuaTeX \pdfpkresolution is not enough
\directlua{kpse.init_prog("luatex", 600, "ljfour")}
\fi
%%% Temporary registers %%%
\newcount\tmpcount
\newdimen\tmpdimen
%%% Auxiliary macros %%%
% Prepend/append #2 to the definition of #1
\long\def\prependef#1#2{\expandafter\def\expandafter#1\expandafter{#2#1}}
\long\def\appendef#1#2{\expandafter\def\expandafter#1\expandafter{#1#2}}
% Variants of \def and \let, where the control sequence name is given as a string
\def\sdef#1{\expandafter\def\csname#1\endcsname}
\def\slet#1#2{\expandafter\let\csname#1\expandafter\endcsname\csname#2\endcsname}
% Assign a control sequence given as a string, complain if it is not defined.
\def\sget#1#2{\ifcsname#2\endcsname
\expandafter\let\expandafter#1\csname#2\endcsname
\else
\errmessage{Undefined control sequence #2}%
\let#1\relax
\fi
}
% Add \protected to an existing macro
\def\addprotected#1{\protected\edef#1{\expandafter\unexpanded\expandafter{#1}}}
% Protect ~
\addprotected~
\def\ucwwarn#1{\immediate\write16{*** UCWmac warning: #1 ***}}
% Replace all occurrences of #1 in \tmpb by #2.
% Thanks to Petr Olsak's OPmac for an efficient implementation.
\bgroup \catcode`!=3 \catcode`?=3
\gdef\replacestrings#1#2{\long\def\replacestringsA##1#1{\def\tmpb{##1}\replacestringsB}%
\long\def\replacestringsB##1#1{\ifx!##1\relax \else\appendef\tmpb{#2##1}%
\expandafter\replacestringsB\fi}%
\expandafter\replacestringsA\tmpb?#1!#1%
\long\def\replacestringsA##1?{\def\tmpb{##1}}\expandafter\replacestringsA\tmpb
}
\egroup
%%% Page size and margins %%%
% If you modify these registers, call \setuppage afterwards
\ifx\luatexversion\undefined
% In LuaTeX, \pagewidth and \pageheight are primitive
% (also, we need \csname here, because \newdimen is \outer)
\csname newdimen\endcsname\pagewidth
\csname newdimen\endcsname\pageheight
\fi
\newdimen\leftmargin
\newdimen\rightmargin
\newdimen\topmargin
\newdimen\bottommargin
\newdimen\evenpageshift
\def\setuppage{%
\hsize=\pagewidth
\advance\hsize by -\leftmargin
\advance\hsize by -\rightmargin
\vsize=\pageheight
\advance\vsize by -\topmargin
\advance\vsize by -\bottommargin
\hoffset=\leftmargin
\advance\hoffset by -1truein
\voffset=\topmargin
\advance\voffset by -1truein
\ifpdf
\pdfhorigin=1truein
\pdfvorigin=1truein
\ifx\luatexversion\undefined
\pdfpagewidth=\pagewidth
\pdfpageheight=\pageheight
\fi
\fi
}
% Set multiple margins to the same value
\def\sethmargins#1{\leftmargin=#1\relax\rightmargin=#1\relax\evenpageshift=0pt\relax}
\def\setvmargins#1{\topmargin=#1\relax\bottommargin=#1\relax}
\def\setmargins#1{\sethmargins{#1}\setvmargins{#1}}
% Define inner/outer margin instead of left/right
\def\setinneroutermargin#1#2{\leftmargin#1\relax\rightmargin#2\relax\evenpageshift=\rightmargin\advance\evenpageshift by -\leftmargin}
% Use a predefined paper format, calls \setuppage automagically
\def\setpaper#1{%
\expandafter\let\expandafter\currentpaper\csname paper-#1\endcsname
\ifx\currentpaper\relax
\errmessage{Undefined paper format #1}
\fi
\currentpaper
}
% Switch to landscape orientation, calls \setuppage automagically
\def\landscape{%
\dimen0=\pageheight
\pageheight=\pagewidth
\pagewidth=\dimen0
\setuppage
}
% Common paper sizes
\def\defpaper#1#2#3{\expandafter\def\csname paper-#1\endcsname{\pagewidth=#2\pageheight=#3\setuppage}}
\defpaper{a3}{297truemm}{420truemm}
\defpaper{a4}{210truemm}{297truemm}
\defpaper{a5}{148truemm}{210truemm}
\defpaper{letter}{8.5truein}{11truein}
\defpaper{legal}{8.5truein}{14truein}
% Default page parameters
\setmargins{1truein}
\setpaper{a4}
%%% Macros with optional arguments %%%
% After \def\a{\withoptarg\b}, the macro \a behaves in this way:
% \a[arg] does \def\optarg{arg} and then it expands \b
% \a does \let\optarg=\relax and then it expands \b
\def\withoptarg#1{\let\xoptcall=#1\futurelet\next\xopt}
\def\xopt{\ifx\next[\expandafter\xoptwith\else\let\optarg=\relax\expandafter\xoptcall\fi}
\def\xoptwith[#1]{\def\optarg{#1}\xoptcall}
% A shortcut for defining macros with optional arguments:
% \optdef\macro behaves as \def\domacro, while \macro itself is defined
% as a wrapper calling \domacro using \withoptarg.
\def\optdef#1{%
\edef\xoptname{\expandafter\eatbackslash\string#1}%
\edef#1{\noexpand\withoptarg\csname do\xoptname\endcsname}%
\expandafter\def\csname do\xoptname\endcsname
}
% Trick: \eatbackslash eats the next backslash of category 12
\begingroup\lccode`\+=`\\
\lowercase{\endgroup\def\eatbackslash+{}}
% Expand to the optional argument if it exists
\def\optargorempty{\ifx\optarg\relax\else\optarg\fi}
%%% Placing material at specified coordinates %%%
% Set all dimensions of a given box register to zero
\def\smashbox#1{\ht#1=0pt \dp#1=0pt \wd#1=0pt}
\long\def\smashedhbox#1{{\setbox0=\hbox{#1}\smashbox0\box0}}
\long\def\smashedvbox#1{{\setbox0=\vbox{#1}\smashbox0\box0}}
% Variants of \llap and \rlap working equally on both sides and/or vertically
\def\hlap#1{\hbox to 0pt{\hss #1\hss}}
\def\vlap#1{\vbox to 0pt{\vss #1\vss}}
\def\clap#1{\vlap{\hlap{#1}}}
% \placeat{right}{down}{hmaterial} places <hmaterial>, so that its
% reference point lies at the given position wrt. the current ref point
\long\def\placeat#1#2#3{\smashedhbox{\hskip #1\lower #2\hbox{#3}}}
% Like \vbox, but with reference point in the upper left corner
\long\def\vhang#1{\vtop{\hrule height 0pt\relax #1}}
% Like \vhang, but respecting interline skips
\long\def\vhanglines#1{\vtop{\hbox to 0pt{}#1}}
% Crosshair with reference point in its center
\def\crosshair#1{\clap{\vrule height 0.2pt width #1}\clap{\vrule height #1 width 0.2pt}}
%%% Output routine %%%
\newbox\pageunderlays
\newbox\pageoverlays
\newbox\commonunderlays
\newbox\commonoverlays
% In addition to the normal page contents, you can define page overlays
% and underlays, which are zero-size vboxes positioned absolutely in the
% front / in the back of the normal material. Also, there are global
% versions of both which are not reset after every page.
\def\addlay#1#2{\setbox#1=\vbox{\ifvbox#1\box#1\fi\nointerlineskip\smashedvbox{#2}}}
\def\pageunderlay{\addlay\pageunderlays}
\def\pageoverlay{\addlay\pageoverlays}
\def\commonunderlay{\addlay\commonoverlays}
\def\commonoverlay{\addlay\commonoverlays}
% Our variation on \plainoutput, which manages inner/outer margins and overlays
\output{\ucwoutput}
\newdimen\pagebodydepth
\def\ucwoutput{\wigglepage\shipout\vbox{%
\makeheadline
\ifvbox\commonunderlays\copy\commonunderlays\nointerlineskip\fi
\ifvbox\pageunderlays\box\pageunderlays\nointerlineskip\fi
\pagebody
\pagebodydepth=\prevdepth
\nointerlineskip
\ifvbox\commonoverlays\vbox to 0pt{\vskip -\vsize\copy\commonoverlays\vss}\nointerlineskip\fi
\ifvbox\pageoverlays\vbox to 0pt{\vskip -\vsize\box\pageoverlays\vss}\nointerlineskip\fi
\prevdepth=\pagebodydepth
\makefootline
}\advancepageno
\ifnum\outputpenalty>-\@MM \else\dosupereject\fi}
\def\wigglepage{\ifodd\pageno\else\advance\hoffset by \evenpageshift\fi}
% Make it easier to redefine footline font (also, fix it so that OFS won't change it unless asked)
\let\footfont=\tenrm
\footline={\hss\footfont\folio\hss}
%%% Itemization %%%
% Usage:
%
% \list{style}
% \:first item
% \:second item
% \endlist
%
% Available styles (others can be defined by \sdef{item:<style>}{<marker>})
%
% o % bullet
% O % empty circle
% * % asterisk
% - % en-dash
% . % dot
% n % 1, 2, 3
% i % i, ii, iii
% I % I, II, III
% a % a, b, c
% A % A, B, C
% g % α, β, γ
%
% Meta-styles (can be used to modify an arbitrary style, currently hard-wired)
%
% #. % with a dot behind
% #) % with a parenthesis behind
% (#) % enclosed in parentheses
% [#] % enclosed in square brackets
%
% Historic usage:
%
% \itemize\ibull % or other marker
% \:first item
% \:second item
% \endlist
%
% \numlist\ndotted % or other numbering style
% \:first
% \:second
% \endlist
% Default dimensions of itemized lists
\newdimen\itemindent \itemindent=0.5in
\newdimen\itemnarrow \itemnarrow=0.5in % make lines narrower by this amount
\newskip\itemmarkerskip \itemmarkerskip=0.4em % between marker and the item
\newskip\preitemizeskip \preitemizeskip=3pt plus 2pt minus 1pt % before the list
\newskip\postitemizeskip \postitemizeskip=3pt plus 2pt minus 1pt % after the list
\newskip\interitemskip \interitemskip=2pt plus 1pt minus 0.5pt % between two items
% Analogues for nested lists
\newdimen\nesteditemindent \nesteditemindent=0.25in
\newdimen\nesteditemnarrow \nesteditemnarrow=0.25in
\newskip\prenesteditemizeskip \prenesteditemizeskip=0pt
\newskip\postnesteditemizeskip \postnesteditemizeskip=0pt
\newif\ifitems\itemsfalse
\newbox\itembox
\newcount\itemcount
% Penalties
\newcount\preitemizepenalty \preitemizepenalty=-500
\newcount\postitemizepenalty \postitemizepenalty=-500
\def\preitemize{
\ifitems
\vskip\prenesteditemizeskip
\advance\leftskip by \nesteditemindent
\advance\rightskip by \nesteditemnarrow
\else
\ifnum\preitemizepenalty=0\else\penalty\preitemizepenalty\fi
\vskip\preitemizeskip
\advance\leftskip by \itemindent
\advance\rightskip by \itemnarrow
\fi
\parskip=\interitemskip
}
\def\postitemize{
\ifitems
\vskip\postnesteditemizeskip
\else
\ifnum\postitemizepenalty=0\else\penalty\postitemizepenalty\fi
\vskip\postitemizeskip
\fi
}
\def\inititemize{\begingroup\preitemize\itemstrue\parindent=0pt}
\def\list#1{\inititemize\itemcount=0\liststyle{#1}\let\:=\listitem}
\def\listitem{\par\leavevmode\advance\itemcount by 1
\llap{\listmarker\hskip\itemmarkerskip}\ignorespaces}
\def\liststyle#1{%
\edef\markertmp{#1}
\ifcsname item:\markertmp\endcsname
\sget\listmarker{item:\markertmp}%
\else
\sget\listmarker{metaitem:\markertometa#1^^X}%
\sget\markerinner{item:\markertoinner#1^^X}%
\fi
}
\def\markertometa#1{%
\ifx#1^^X%
\else
\ifx#1((%
\else\ifx#1[[%
\else\ifx#1))%
\else\ifx#1]]%
\else\ifx#1..%
\else=%
\fi\fi\fi\fi\fi
\expandafter\markertometa
\fi
}
\def\markertoinner#1{%
\ifx#1^^X%
\else
\ifx#1(%
\else\ifx#1)%
\else\ifx#1[%
\else\ifx#1]%
\else\ifx#1.%
\else#1%
\fi\fi\fi\fi\fi
\expandafter\markertoinner
\fi
}
\def\endlist{\par\endgroup\postitemize}
% List styles
\sdef{item:o}{\raise0.2ex\hbox{$\bullet$}}
\sdef{item:O}{\raise0.2ex\hbox{$\circ$}}
\sdef{item:*}{\raise0.2ex\hbox{$\ast$}}
\sdef{item:-}{--}
\sdef{item:.}{\raise0.2ex\hbox{$\cdot$}}
\sdef{item:n}{\the\itemcount}
\sdef{item:i}{\romannumeral\itemcount}
\sdef{item:I}{\uppercase\expandafter{\romannumeral\itemcount}}
\sdef{item:a}{\char\numexpr 96+\itemcount\relax}
\sdef{item:A}{\char\numexpr 64+\itemcount\relax}
\sdef{item:g}{$\ifcase\itemcount\or\alpha\or\beta\or\gamma\or\delta\or\epsilon\or
\zeta\or\eta\or\theta\or\iota\or\kappa\or\lambda\or\mu\or\nu\or\xi\or\pi\or\rho
\or\sigma\or\tau\or\upsilon\or\phi\or\chi\or\psi\or\omega\fi$}
% List meta-styles
\sdef{metaitem:=.}{\markerinner.}
\sdef{metaitem:=)}{\markerinner)}
\sdef{metaitem:(=)}{(\markerinner)}
\sdef{metaitem:[=]}{[\markerinner]}
% Old-style lists
\def\itemize#1{\inititemize\setbox\itembox\llap{#1\hskip\itemmarkerskip}%
\let\:=\singleitem}
\def\singleitem{\par\leavevmode\copy\itembox\ignorespaces}
\def\numlist#1{\inititemize\itemcount=0\let\:=\numbereditem
\let\itemnumbering=#1}
\def\numbereditem{\par\leavevmode\advance\itemcount by 1
\llap{\itemnumbering\hskip\itemmarkerskip}\ignorespaces}
% Old-style markers
\def\ibull{\raise0.2ex\hbox{$\bullet$}}
\def\idot{\raise0.2ex\hbox{$\cdot$}}
\def\istar{\raise0.2ex\hbox{$\ast$}}
\def\nnorm{\the\itemcount}
\def\ndotted{\nnorm.}
\def\nparen{\nnorm)}
\def\nparenp{(\nnorm)}
\def\nroman{\romannumeral\itemcount}
\def\nromanp{\nroman)}
\def\nalpha{\count@=96\advance\count@ by\itemcount\char\count@)}
\def\nAlpha{\count@=64\advance\count@ by\itemcount\char\count@)}
\def\ngreek{$\ifcase\itemcount\or\alpha\or\beta\or\gamma\or\delta\or\epsilon\or
\zeta\or\eta\or\theta\or\iota\or\kappa\or\lambda\or\mu\or\nu\or\xi\or\pi\or\rho
\or\sigma\or\tau\or\upsilon\or\phi\or\chi\or\psi\or\omega\fi$)}
%%% Miscellanea %%%
% {\I italic} with automatic italic correction
\def\I{\it\aftergroup\/}
% A breakable dash, to be repeated on the next line
\def\={\discretionary{-}{-}{-}}
% Non-breakable identifiers
\def\<#1>{\leavevmode\hbox{\I #1}}
% Handy shortcuts
\let\>=\noindent
\def\\{\hfil\break}
% Variants of \centerline, \leftline and \rightline, which are compatible with
% verbatim environments and other catcode hacks
\def\cline{\bgroup\def\linet@mp{\aftergroup\box\aftergroup0\aftergroup\egroup\hss\bgroup\aftergroup\hss\aftergroup\egroup}\afterassignment\linet@mp\setbox0\hbox to \hsize}
\def\lline{\bgroup\def\linet@mp{\aftergroup\box\aftergroup0\aftergroup\egroup\bgroup\aftergroup\hss\aftergroup\egroup}\afterassignment\linet@mp\setbox0\hbox to \hsize}
\def\rline{\bgroup\def\linet@mp{\aftergroup\box\aftergroup0\aftergroup\egroup\hss\bgroup\aftergroup\egroup}\afterassignment\linet@mp\setbox0\hbox to \hsize}
% Insert a PDF picture
% \putimage{width specification}{file}
\def\putimage#1#2{\hbox{\pdfximage #1{#2}\pdfrefximage\pdflastximage}}
%%% Colors %%%
% Use of pdfTeX color stack:
% \colorpush\rgb{1 0 0} puts a new color on the stack
% \colorset\rgb{1 0 0} replaces the top color on the stack
% \colorpop pops the top color
% \colorlocal\rgb{1 0 0} set a color locally until the end of the current group
\chardef\colorstk=\pdfcolorstackinit page direct{0 g 0 G}
\def\colorset#1{\pdfcolorstack\colorstk set #1}
\def\colorpush#1{\pdfcolorstack\colorstk push #1}
\def\colorpop{\pdfcolorstack\colorstk pop}
\def\colorlocal{\aftergroup\colorpop\colorpush}
% Different ways of describing colors: \rgb{R G B}, \gray{G}, \cmyk{C M Y K}
% (all components are real numbers between 0 and 1)
\def\rgb#1{{#1 rg #1 RG}}
\def\gray#1{{#1 g #1 G}}
\def\cmyk#1{{#1 k #1 K}}
%%% Localization %%%
% Define a new localized string: \localedef{language}{identifier}{message}
% (we use \language codes to identify languages)
\def\localedef#1#2{\tmpcount=#1\expandafter\def\csname loc:\the\tmpcount:#2\endcsname}
% Expand a localized string in the current language: \localemsg{identifier}
\def\localestr#1{%
\ifcsname loc:\the\language:#1\endcsname
\csname loc:\the\language:#1\endcsname
\else
\ucwwarn{Localized string #1 not defined in language \the\language}%
???%
\fi
}
%%% Modules %%%
% Require a module: load it if it is not already loaded
\def\ucwmodule#1{
\ifcsname ucwmod:#1\endcsname
\else
\input ucw-#1.tex
\fi
}
% Definition of a new module (to be placed at the beginning of its file)
% (Also guards against repeated loading if somebody uses \input instead of \ucwmodule.)
\def\ucwdefmodule#1{
\ifcsname ucwmod:#1\endcsname\endinput\fi
\expandafter\let\csname ucwmod:#1\endcsname=\relax
}
%%% Epilog %%%
% Let's hide all internal macros
\catcode`@=12
# Podepsané tokeny
import hashlib
import hmac
import urllib.parse
from typing import Optional, List
......@@ -8,9 +9,7 @@ import mo.config as config
def _sign_token(token: str, use: str) -> str:
key = '-'.join(('sign-token', use, config.SECRET_KEY))
mac = hmac.HMAC(key.encode('us-ascii'), token.encode('us-ascii'), 'sha256')
return mac.hexdigest()[:16]
return sign(token, 'token-' + use)
def sign_token(fields: List[str], use: str) -> str:
......@@ -27,3 +26,18 @@ def verify_token(token: str, use: str) -> Optional[List[str]]:
if _sign_token(':'.join(enc_fields), use) != sign:
return None
return [urllib.parse.unquote(f) for f in enc_fields]
def sign(msg: str, use: str) -> str:
"""Podpis parametrizovaný tajným klíčem a účelem."""
key = use + '#' + config.SECRET_KEY
mac = hmac.HMAC(key.encode('us-ascii'), msg.encode('us-ascii'), 'sha256')
return mac.hexdigest()[:16]
def hash(msg: str, use: str) -> str:
"""Hešovací funkce parametrizovaná tajným klíčem a účelem."""
m = '#'.join((use, config.SECRET_KEY, msg)).encode('us-ascii')
return hashlib.sha256(m).hexdigest()
......@@ -2,17 +2,140 @@
import bcrypt
import datetime
import dateutil.tz
import email.errors
import email.headerregistry
import re
from typing import Optional
import secrets
from typing import Optional, Tuple
import mo
import mo.config as config
import mo.db as db
import mo.util
from mo.util import logger
import mo.tokens
def normalize_grade(rocnik: str, school: db.School) -> str:
""" Aktuálně provádí jen kontrolu formátu. """
if not re.fullmatch(r'\d(/\d)?', rocnik):
raise mo.CheckError('Ročník má neplatný formát, musí to být buď číslice, nebo číslice/číslice')
if not school.is_zs and re.fullmatch(r'\d', rocnik):
raise mo.CheckError(f'Ročník pro střední školu ({school.place.name}) zapisujte ve formátu číslice/číslice')
if not school.is_ss and re.fullmatch(r'\d/\d', rocnik):
raise mo.CheckError(f'Ročník pro základní školu ({school.place.name}) zapisujte jako číslici 1–9')
return rocnik
def validate_born_year(r: int) -> None:
if r < 2000 or r > 2099:
raise mo.CheckError('Rok narození musí být v intervalu [2000,2099]')
def validate_and_find_school(kod: str) -> db.Place:
if kod == "":
raise mo.CheckError('Škola je povinná')
place = db.get_place_by_code(kod, fetch_school=True)
if not place:
raise mo.CheckError(f'Škola s kódem "{kod}" nenalezena' +
('. Nechybí vám # na začátku?' if re.fullmatch(r'\d+', kod) else ''))
if place.type != db.PlaceType.school:
raise mo.CheckError(f'Kód školy "{kod}" neodpovídá škole')
return place
def find_or_create_user(email: str, krestni: Optional[str], prijmeni: Optional[str], is_org: bool, reason: str) -> Tuple[db.User, bool]:
sess = db.get_session()
user = sess.query(db.User).filter_by(email=email).one_or_none()
is_new = user is None
if user is None: # HACK: Podmínku je nutné zapsat znovu místo užití is_new, jinak si s tím mypy neporadí
if not krestni or not prijmeni:
raise mo.CheckError('Osoba s daným emailem zatím neexistuje, je nutné uvést její jméno.')
user = db.User(email=email, first_name=krestni, last_name=prijmeni, is_org=is_org)
sess.add(user)
sess.flush() # Aby uživatel dostal user_id
logger.info(f'{reason.title()}: Založen uživatel user=#{user.user_id} email=<{user.email}>')
mo.util.log(
type=db.LogType.user,
what=user.user_id,
details={'action': 'create-user', 'reason': reason, 'new': db.row2dict(user)},
)
else:
if (krestni and user.first_name != krestni) or (prijmeni and user.last_name != prijmeni):
raise mo.CheckError(f'Osoba již registrována s odlišným jménem {user.full_name()}')
if (user.is_admin or user.is_org) != is_org:
if is_org:
raise mo.CheckError('Nelze předefinovat účastníka na organizátora')
else:
raise mo.CheckError('Nelze předefinovat organizátora na účastníka')
return user, is_new
def find_or_create_participant(user: db.User, year: int, school_id: Optional[int], birth_year: Optional[int], grade: Optional[str], reason: str) -> Tuple[db.Participant, bool]:
sess = db.get_session()
part = sess.query(db.Participant).get((user.user_id, year))
is_new = part is None
if part is None:
if not school_id:
raise mo.CheckError('Osoba s daným emailem zatím není zaregistrovaná do ročníku, je nutné uvést školu.')
if not birth_year:
raise mo.CheckError('Osoba s daným emailem zatím není zaregistrovaná do ročníku, je nutné uvést rok narození.')
if not grade:
raise mo.CheckError('Osoba s daným emailem zatím není zaregistrovaná do ročníku, je nutné uvést ročník.')
part = db.Participant(user=user, year=year, school=school_id, birth_year=birth_year, grade=grade)
sess.add(part)
logger.info(f'{reason.title()}: Založen účastník #{user.user_id}')
mo.util.log(
type=db.LogType.participant,
what=user.user_id,
details={'action': 'create-participant', 'reason': reason, 'new': db.row2dict(part)},
)
else:
if ((school_id and part.school != school_id)
or (grade and part.grade != grade)
or (birth_year and part.birth_year != birth_year)):
raise mo.CheckError('Účastník již zaregistrován s odlišnou školou/ročníkem/rokem narození')
return part, is_new
def find_or_create_participation(user: db.User, contest: db.Contest, place: Optional[db.Place], reason: str) -> Tuple[db.Participation, bool]:
if place is None:
place = contest.place
sess = db.get_session()
pions = (sess.query(db.Participation)
.filter_by(user=user)
.filter(db.Participation.contest.has(db.Contest.round == contest.round))
.all())
is_new = pions == []
if is_new:
pion = db.Participation(user=user, contest=contest, place_id=place.place_id, state=db.PartState.active)
sess.add(pion)
logger.info(f'{reason.title()}: Založena účast user=#{user.user_id} contest=#{contest.contest_id} place=#{place.place_id}')
mo.util.log(
type=db.LogType.participant,
what=user.user_id,
details={'action': 'add-to-contest', 'reason': reason, 'new': db.row2dict(pion)},
)
elif len(pions) == 1:
pion = pions[0]
if pion.place != place:
raise mo.CheckError(f'Již se tohoto kola účastní v {contest.round.get_level().name_locative("jiném", "jiné", "jiném")} ({pion.place.get_code()})')
else:
raise mo.CheckError('Již se tohoto kola účastní ve více oblastech, což by nemělo být možné')
return pion, is_new
def normalize_email(addr: str) -> str:
if not re.fullmatch(r'.+@.+', addr):
raise mo.CheckError('V e-mailové adrese chybí zavináč')
......@@ -53,17 +176,23 @@ def user_by_uid(uid: int) -> db.User:
return db.get_session().query(db.User).get(uid)
def set_password(user: db.User, passwd: str):
password_help = 'Heslo musí mít alespoň 8 znaků. Doporučujeme kombinovat velká a malá písmena a číslice.'
def validate_password(passwd: str) -> bool:
return len(passwd) >= 8
def set_password(user: db.User, passwd: str, reset: bool = False):
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(passwd.encode('utf-8'), salt)
user.password_hash = hashed.decode('us-ascii')
user.reset_at = None
user.last_login_at = datetime.datetime.now()
if reset:
user.reset_at = mo.now
mo.util.log(
type=db.LogType.user,
what=user.user_id,
details={"action": "set-passwd"},
details={'action': 'do-reset'},
)
......@@ -73,63 +202,68 @@ def check_password(user: db.User, passwd: str):
def login(user: db.User):
user.last_login_at = datetime.datetime.now()
user.reset_at = None
def ask_reset_password(user: db.User) -> str:
user.reset_at = datetime.datetime.now()
when = int(user.reset_at.timestamp())
token = mo.tokens.sign_token([str(user.user_id), str(when)], 'reset')
mo.util.log(
type=db.LogType.user,
what=user.user_id,
details={'action': 'ask-reset'},
)
user.last_login_at = mo.now
return token
def make_activation_token(user: db.User) -> str:
user.reset_at = mo.now
when = int(mo.now.timestamp())
return mo.tokens.sign_token([str(user.user_id), str(when)], 'activate')
def check_reset_password(token: str) -> Optional[db.User]:
# Někteří klienti při kopírování adresy z mailu do prohlížeče
# přidávají divné Unicodové znaky (přepnutí směru psaní atd., viz issue #58).
token = re.sub(r'[^!-~]', "", token)
fields = mo.tokens.verify_token(token, 'reset')
def check_activation_token(token: str) -> Optional[db.User]:
token = mo.util.clean_up_token(token)
fields = mo.tokens.verify_token(token, 'activate')
if not fields or len(fields) != 2:
return None
user = db.get_session().query(db.User).filter_by(user_id=int(fields[0])).first()
user_id = int(fields[0])
token_time = datetime.datetime.fromtimestamp(int(fields[1]), tz=dateutil.tz.UTC)
if user.password_hash is None:
reset_token_validity_time = datetime.timedelta(days=28)
user = user_by_uid(user_id)
if not user:
return None
elif token_time < mo.now - datetime.timedelta(days=28):
return None
else:
reset_token_validity_time = datetime.timedelta(hours=24)
return user
now = datetime.datetime.now().astimezone()
if (user
and user.reset_at is not None
and fields[1] == str(int(user.reset_at.timestamp()))
and now - user.reset_at < reset_token_validity_time):
return user
else:
def new_reg_request(type: db.RegReqType, client: str) -> Optional[db.RegRequest]:
sess = db.get_session()
# Zatím jen jednoduchý rate limit, časem možno vylepšit
in_last_minute = db.get_count(sess.query(db.RegRequest).filter(db.RegRequest.created_at >= mo.now - datetime.timedelta(minutes=1)))
if in_last_minute >= config.REG_MAX_PER_MINUTE:
return None
email_token = mo.tokens.sign_token([str(int(mo.now.timestamp())), secrets.token_hex(16)], 'reg-request')
def cancel_reset_password(user: db.User):
user.reset_at = None
mo.util.log(
type=db.LogType.user,
what=user.user_id,
details={'action': 'cancel-reset'},
return db.RegRequest(
type=type,
created_at=mo.now,
expires_at=mo.now + datetime.timedelta(minutes=config.REG_TOKEN_VALIDITY),
email_token=email_token,
client=client,
)
def do_reset_password(user: db.User):
user.reset_at = None
def expire_reg_requests():
sess = db.get_session()
conn = sess.connection()
table = db.RegRequest.__table__
conn.execute(table.delete().where(table.c.expires_at < mo.now))
sess.commit()
def request_reset_password(user: db.User, client: str) -> Optional[db.RegRequest]:
logger.info('Login: Požadavek na reset hesla pro <%s>', user.email)
rr = new_reg_request(db.RegReqType.reset_passwd, client)
if rr:
db.get_session().add(rr)
rr.user_id = user.user_id
mo.util.log(
type=db.LogType.user,
what=user.user_id,
details={'action': 'do-reset'},
details={'action': 'ask-reset'},
)
return rr
......@@ -4,18 +4,13 @@ from dataclasses import dataclass
import datetime
import decimal
import dateutil.tz
import email.message
import email.headerregistry
import locale
import logging
import os
import re
import secrets
import subprocess
import sys
from typing import Any, Optional, NoReturn, Tuple
import textwrap
import urllib.parse
from typing import Any, Optional, NoReturn, Tuple, List
import mo
import mo.db as db
......@@ -60,88 +55,6 @@ def log(type: db.LogType, what: int, details: Any):
db.get_session().add(entry)
def send_user_email(user: db.User, subject: str, body: str) -> bool:
logger.info(f'Mail: "{subject}" -> {user.email}')
mail_from = getattr(config, 'MAIL_FROM', None)
if mail_from is None:
logger.error('Mail: V configu chybí nastavení MAIL_FROM')
return False
msg = email.message.EmailMessage()
msg['From'] = email.headerregistry.Address(
display_name='Odevzdávací Systém MO',
addr_spec=mail_from,
)
msg['To'] = [
email.headerregistry.Address(
display_name=user.full_name(),
addr_spec=user.email,
)
]
msg['Reply-To'] = email.headerregistry.Address(
display_name='Správce OSMO',
addr_spec=config.MAIL_CONTACT,
)
msg['Subject'] = 'OSMO – ' + subject
msg['Date'] = datetime.datetime.now()
msg.set_content(body, cte='quoted-printable')
mail_instead = getattr(config, 'MAIL_INSTEAD', None)
if mail_instead is None:
send_to = user.email
else:
send_to = mail_instead
sm = subprocess.Popen(
[
'/usr/sbin/sendmail',
'-oi',
'-f',
mail_from,
send_to,
],
stdin=subprocess.PIPE,
)
sm.communicate(msg.as_bytes())
if sm.returncode != 0:
logger.error('Mail: Sendmail failed with return code {}'.format(sm.returncode))
return False
return True
def password_reset_url(token: str) -> str:
return config.WEB_ROOT + 'auth/reset?' + urllib.parse.urlencode({'token': token}, safe=':')
def send_new_account_email(user: db.User, token: str) -> bool:
return send_user_email(user, 'Založen nový účet', textwrap.dedent('''\
Vítejte!
Právě Vám byl založen účet v Odevzdávacím systému Matematické olympiády.
Nastavte si prosím heslo na následující stránce:
{}
Váš OSMO
'''.format(password_reset_url(token))))
def send_password_reset_email(user: db.User, token: str) -> bool:
return send_user_email(user, 'Obnova hesla', textwrap.dedent('''\
Někdo (pravděpodobně Vy) požádal o obnovení hesla k Vašemu účtu v Odevzdávacím
systému Matematické olympiády. Heslo si můžete nastavit, případně požadavek
zrušit, na následující stránce:
{}
Váš OSMO
'''.format(password_reset_url(token))))
def die(msg: str) -> NoReturn:
print(msg, file=sys.stderr)
sys.exit(1)
......@@ -176,6 +89,11 @@ def data_dir(name: str) -> str:
return os.path.join(config.DATA_DIR, name)
def part_path(name: str) -> str:
"""Vrátí cestu k datovém souboru, který se instaluje jako součást pythoních modulů."""
return os.path.normpath(os.path.join(__file__, "..", name))
def link_to_dir(src: str, dest_dir: str, prefix: str = "", suffix: str = "") -> str:
"""Vytvoří hardlink na zdrojový soubor pod unikátním jménem v cílovém adresáři."""
......@@ -247,3 +165,34 @@ def check_points(points: decimal.Decimal, for_task: Optional[db.Task] = None, fo
else:
return f'Podle nastavení kola zadat body jen s krokem {points_step} (hodnota {points} je neplatná)'
return None
def parse_int_list(a: str, maxim: int = 200) -> List[int]:
"""Parsuje "1-3,5,7-9" na [1,2,3,5,7,9].
Aby nešlo generovat moc velká pole (obrana proti DDoSu),
existuje omezení na velikost."""
r: List[int] = []
for i in a.split(","):
b = i.split("-")
if len(b) > 2:
raise mo.CheckError("Nadměrný počet pomlček")
try:
c = list(map(int, b))
except ValueError:
raise mo.CheckError("Převod na číslo se nezdařil")
if any(x < 0 or x > maxim for x in c):
raise mo.CheckError("Překročen limit na velikost čísla")
if len(c) == 2 and c[0] > c[1]:
raise mo.CheckError("Větší číslo nemůže být před menším")
r += [c[0]] if len(c) == 1 else range(c[0], c[1] + 1)
return r
def clean_up_token(token: str) -> str:
# Někteří klienti při kopírování adresy z mailu do prohlížeče
# přidávají divné Unicodové znaky (přepnutí směru psaní atd., viz issue #58).
return re.sub(r'[^!-~]', "", token)
def star_is_none(x):
return None if x == "*" else x
......@@ -41,6 +41,13 @@ def timeformat(dt: datetime) -> str:
return dt.astimezone().strftime("%Y-%m-%d %H:%M")
def timeformat_short(dt: datetime) -> str:
if dt is None:
return ''
else:
return dt.astimezone().strftime("%Y-%m-%d")
def timedelta(d: datetime, ref: Optional[datetime] = None, descriptive: bool = False) -> str:
"""Vyrábí česky formátované řetězece 'za 3 minuty', 'před 27 dny' a podobně
z rozdílu daného datetime a referenčního času (například now).
......
......@@ -115,6 +115,7 @@ app.assets.add_assets([
'bootstrap.min.css',
'mo.css',
'js/news-reloader.js',
'js/osmo.js',
])
......@@ -141,7 +142,7 @@ def init_request():
if not user:
# Uživatel mezitím přestal existovat
app.logger.error('Zrušena session pro neexistujícího uživatele uid=%s', session['uid'])
return mo.web.auth.logout()
return mo.web.acct.logout()
else:
user = None
......@@ -171,18 +172,28 @@ app.before_request(init_request)
### UWSGI glue ###
# Čas od času se probudíme a spustíme garbage collector:
# - projdeme joby pro případ, že by se ztratil signál
# - expirujeme zastaralé joby
# - expirujeme zastaralé registrační tokeny
@app.cli.command('gc')
def gc():
"""Run garbage collector."""
mo.now = mo.util.get_now()
mo.jobs.process_jobs()
mo.users.expire_reg_requests()
try:
import uwsgi
from uwsgidecorators import timer, signal
# Čas od času se probudíme a projdeme joby pro případ, že by se ztratil signál.
# Také při tom expirujeme zastaralé joby.
@timer(config.JOB_GC_PERIOD, target='mule')
@timer(config.GC_PERIOD, target='mule')
def mule_timer(signum):
# app.logger.debug('Mule: Timer tick')
with app.app_context():
mo.now = mo.util.get_now()
mo.jobs.process_jobs()
garbage_collect()
# Obykle při vložení jobu dostaneme signál.
@signal(42, target='mule')
......@@ -205,7 +216,8 @@ except ImportError:
# Většina webu je v samostatných modulech
import mo.web.auth
import mo.web.api
import mo.web.acct
import mo.web.jinja
import mo.web.menu
import mo.web.misc
......
This diff is collapsed.
from flask import request
from flask.json import jsonify
from sqlalchemy import func
from sqlalchemy.orm import joinedload
import werkzeug.exceptions
import mo.db as db
from mo.util_format import inflect_with_number
from mo.web import app
@app.route('/api/')
def api_root():
"""Slouží jako prefix pro konstrukci URL v JavaScriptu."""
raise werkzeug.exceptions.NotFound()
@app.route('/api/find-town')
def api_find_town():
query = request.args.get('q')
if query is None or len(query) < 2:
return jsonify(error='Zadejte alespoň 2 znaky jména obce.')
elif '%' in query:
return jsonify(error='Nepovolené znaky ve jménu obce.')
else:
max_places = 50
places = (db.get_session().query(db.Place)
.filter_by(level=3)
.filter(func.lower(db.f_unaccent(db.Place.name)).like(func.lower(db.f_unaccent(query + '%'))))
.options(joinedload(db.Place.parent_place))
.order_by(db.Place.name, db.Place.place_id)
.limit(max_places)
.all())
if not places:
return jsonify(error='Nenalezena žádná obec.')
# XXX: Nemůže se stát, že nastane přesná shoda a k tomu příliš mnoho nepřesných?
if len(places) >= max_places:
return jsonify(error='Nalezeno příliš mnoho obcí. Zadejte prosím více znaků jména.')
res = []
for p in places:
name = p.name
if p.name != p.parent_place.name:
name += f' (okres {p.parent_place.name})'
res.append([p.place_id, name])
msg = inflect_with_number(len(res), 'Nalezena %s obec.', 'Nalezeny %s obce.', 'Nalezeno %s obcí.')
return jsonify(found=res, msg=msg)
@app.route('/api/get-schools')
def api_get_schools():
town = request.args.get('town')
if town is None or not town.isnumeric():
raise werkzeug.exceptions.BadRequest()
town_id = int(town)
places = (db.get_session().query(db.Place)
.filter_by(level=4, type=db.PlaceType.school, parent=town_id)
.options(joinedload(db.Place.school))
.order_by(db.Place.name)
.all())
zs = []
ss = []
for p in places:
s = {
'id': p.place_id,
'name': p.name,
}
if p.school.is_zs:
zs.append(s)
if p.school.is_ss:
ss.append(s)
return jsonify(zs=zs, ss=ss)
import datetime
from flask import render_template, request, g, redirect, url_for, session
from flask.helpers import flash
from flask_wtf import FlaskForm
import werkzeug.exceptions
import wtforms
from wtforms.fields.html5 import EmailField
import wtforms.validators as validators
from sqlalchemy.orm import joinedload
from typing import Optional
import mo.util
import mo.db as db
import mo.rights
import mo.users
from mo.web import app, NeedLoginError
class LoginForm(FlaskForm):
next = wtforms.HiddenField()
email = EmailField('E-mail', validators=[validators.DataRequired()])
passwd = wtforms.PasswordField('Heslo')
submit = wtforms.SubmitField('Přihlásit se')
reset = wtforms.SubmitField('Zapomenuté heslo')
def login_and_redirect(user: db.User, url: Optional[str] = None):
session.clear()
session['uid'] = user.user_id
if not url:
if user.is_admin or user.is_org:
url = url_for('org_index')
else:
url = url_for('index')
else:
url = request.script_root + url
return redirect(url)
@app.route('/auth/login', methods=('GET', 'POST'))
def login():
form = LoginForm(email=request.args.get('email'))
if not form.validate_on_submit():
return render_template('login.html', form=form, error=None)
email = form.email.data
user = mo.users.user_by_email(email)
if not user:
app.logger.error('Login: Neznámý uživatel <%s>', email)
flash('Neznámý uživatel', 'danger')
elif form.reset.data:
app.logger.info('Login: Požadavek na reset hesla pro <%s>', email)
min_time_between_resets = datetime.timedelta(minutes=1)
now = datetime.datetime.now().astimezone()
if (user.reset_at is not None
and now - user.reset_at < min_time_between_resets):
flash('Poslední požadavek na obnovení hesla byl odeslán příliš nedávno', 'danger')
else:
token = mo.users.ask_reset_password(user)
db.get_session().commit()
mo.util.send_password_reset_email(user, token)
flash('Na uvedenou adresu byl odeslán e-mail s odkazem na obnovu hesla', 'success')
elif not form.passwd.data or not mo.users.check_password(user, form.passwd.data):
app.logger.error('Login: Špatné heslo pro uživatele <%s>', email)
flash('Chybné heslo', 'danger')
else:
if user.is_admin:
typ = ' (admin)'
elif user.is_org:
typ = ' (org)'
elif user.is_test:
typ = ' (test)'
else:
typ = ""
app.logger.info('Login: Přihlásil se uživatel #%s <%s>%s', user.user_id, email, typ)
mo.users.login(user)
db.get_session().commit()
return login_and_redirect(user, url=form.next.data)
return render_template('login.html', form=form)
@app.route('/auth/logout', methods=('POST',))
def logout():
session.clear()
return redirect(url_for('index'))
@app.route('/auth/incarnate/<int:id>', methods=('POST',))
def incarnate(id):
if not g.user.is_admin:
raise werkzeug.exceptions.Forbidden()
new_user = db.get_session().query(db.User).get(id)
if not new_user:
raise werkzeug.exceptions.NotFound()
app.logger.info('Login: Uživatel #%s se převtělil na #%s', g.user.user_id, new_user.user_id)
return login_and_redirect(new_user)
@app.route('/user/settings')
def user_settings():
sess = db.get_session()
roles = []
if g.user:
roles = (sess.query(db.UserRole)
.filter_by(user_id=g.user.user_id)
.options(joinedload(db.UserRole.place))
.all())
return render_template('settings.html', roles=roles, roles_by_type=mo.rights.roles_by_type)
@app.errorhandler(NeedLoginError)
def handle_need_login(e):
form = LoginForm()
form.next.data = request.path
return render_template('login.html', form=form), e.code
class ResetForm(FlaskForm):
email = EmailField('E-mail', description='Účet pro který se nastavuje nové heslo', render_kw={"disabled": "disabled"})
token = wtforms.HiddenField()
passwd = wtforms.PasswordField('Nové heslo', description='Heslo musí mít alespoň 8 znaků. Doporučujeme kombinovat velká a malá písmena a číslice.')
submit = wtforms.SubmitField('Nastavit heslo')
cancel = wtforms.SubmitField('Zrušit obnovu hesla')
@app.route('/auth/reset', methods=('GET', 'POST'))
def reset():
token = request.args.get('token')
if not token:
flash('Žádný token pro resetování hesla', 'danger')
return redirect(url_for('login'))
user = mo.users.check_reset_password(token)
if not user:
flash('Neplatný požadavek na obnovu hesla', 'danger')
return redirect(url_for('login'))
form = ResetForm(token=token, email=user.email)
ok = form.validate_on_submit()
if not ok:
return render_template('reset.html', form=form)
if form.cancel.data:
mo.users.cancel_reset_password(user)
app.logger.info('Login: Zrušen reset hesla pro uživatele <%s>', user.email)
db.get_session().commit()
flash('Obnova hesla zrušena', 'warning')
return redirect(url_for('login'))
elif len(form.passwd.data) < 8:
flash('Heslo musí být aspoň 8 znaků dlouhé', 'danger')
return render_template('reset.html', form=form)
else:
mo.users.do_reset_password(user)
mo.users.set_password(user, form.passwd.data)
app.logger.info('Login: Reset hesla pro uživatele <%s>', user.email)
mo.util.log(
type=db.LogType.user,
what=user.user_id,
details={'action': 'reset-passwd'},
)
mo.users.login(user)
app.logger.info('Login: Přihlásil se uživatel <%s> po resetování hesla', user.email)
db.get_session().commit()
flash('Nastavení nového hesla a přihlášení do systému proběhlo úspěšně', 'success')
return login_and_redirect(user)
@app.errorhandler(werkzeug.exceptions.Forbidden)
def handle_forbidden(e):
return render_template('forbidden.html')
import decimal
from typing import Optional
import wtforms
from wtforms.fields.html5 import EmailField
from wtforms.widgets.html5 import NumberInput
import mo
import mo.users
import mo.db as db
class OptionalInt(wtforms.IntegerField):
widget = NumberInput()
def process_formdata(self, valuelist):
self.data = None
if valuelist:
if valuelist[0]:
try:
self.data = int(valuelist[0])
except ValueError:
raise wtforms.ValidationError('Nejedná se o číslo.')
class Decimal(wtforms.DecimalField):
"""Upravený DecimalField, který formátuje číslo podle jeho skutečného počtu
desetinných míst a zadané `places` používá jen jako maximální počet desetinných míst."""
def _value(self):
if self.data is not None:
# Spočítání počtu desetinných míst, zbytek necháme na původní implementaci
max_places = self.places
self.places = 0
d = decimal.Decimal(1)
while self.data % d != 0 and self.places < max_places:
self.places += 1
d /= 10
return super(Decimal, self)._value()
class IntList(wtforms.StringField):
list = None
def __init__(self, label="", validators=None, **kwargs):
super().__init__(label, validators, **kwargs)
def pre_validate(field, form):
field.list = None
if field.data:
try:
field.list = mo.util.parse_int_list(field.data)
except mo.CheckError as e:
raise wtforms.ValidationError(str(e))
class Points(Decimal):
def __init__(self, label="Body", validators=None, **kwargs):
super().__init__(label, validators, **kwargs)
class Email(EmailField):
def __init__(self, label="E-mail", validators=None, **kwargs):
super().__init__(label, validators, **kwargs)
def pre_validate(field, form):
if field.data:
try:
field.data = mo.users.normalize_email(field.data)
except mo.CheckError as e:
raise wtforms.ValidationError(str(e))
class Grade(wtforms.StringField):
"""Pro validaci hledá ve formuláři form.school a podle ní rozlišuje SŠ a ZŠ """
default_description = "Pro základní školy je to číslo od 1 do 9, pro <var>k</var>-tý ročník <var>r</var>-leté střední školy má formát <var>k</var>/<var>r</var>."
validate_grade = True
def __init__(self, label="Ročník", validators=None, description=default_description, **kwargs):
super().__init__(label, validators, description=description, **kwargs)
def pre_validate(field, form):
if field.data:
if field.validate_grade:
school_place = form.school.get_place()
if school_place is not None:
try:
field.data = mo.users.normalize_grade(field.data, school_place.school)
except mo.CheckError as e:
raise wtforms.ValidationError(str(e))
class BirthYear(OptionalInt):
def __init__(self, label="Rok narození", validators=None, **kwargs):
super().__init__(label, validators, **kwargs)
def pre_validate(field, form):
if field.data is not None:
r: int = field.data
try:
mo.users.validate_born_year(r)
except mo.CheckError as e:
raise wtforms.ValidationError(str(e))
class Name(wtforms.StringField):
def pre_validate(field, form):
# XXX: Tato kontrola úmyslně není striktní, aby prošla i jména jako 'de Beer'
if field.data:
if field.data == field.data.lower():
raise wtforms.ValidationError('Ve jméně nejsou velká písmena.')
if field.data == field.data.upper():
raise wtforms.ValidationError('Ve jméně nejsou malá písmena.')
class FirstName(Name):
def __init__(self, label="Jméno", validators=None, **kwargs):
super().__init__(label, validators, **kwargs)
class LastName(Name):
def __init__(self, label="Příjmení", validators=None, **kwargs):
super().__init__(label, validators, **kwargs)
class Place(wtforms.StringField):
def __init__(self, label="Místo", validators=None, **kwargs):
super().__init__(label, validators, **kwargs)
self.render_kw = {"placeholder": "Kód"}
place_loaded: bool = False
place: Optional[db.Place] = None
place_error: str = ""
def load_place(field) -> None:
field.place = None
field.place_error = ""
if field.data:
field.place = db.get_place_by_code(field.data)
if field.place is None:
field.place_error = "Zadané místo nenalezeno."
def get_place(field) -> Optional[db.Place]:
""" Kešuje výsledek v field.place"""
if not field.place_loaded:
field.place_loaded = True
field.load_place()
return field.place
def pre_validate(field, form):
if field.get_place() is None and field.place_error:
raise wtforms.ValidationError(field.place_error)
def get_place_id(field) -> int:
p = field.get_place()
if p is None:
return 0
return p.place_id
def populate_obj(field, obj, name):
setattr(obj, name, field.get_place_id())
def process_data(field, obj: Optional[int]):
if obj is not None:
field.data = db.get_place_by_id(obj).get_code()
else:
field.data = ""
class School(Place):
def __init__(self, label="Škola", validators=None, **kwargs):
super().__init__(label, validators, **kwargs)
def load_place(field) -> None:
field.place = None
field.place_error = ""
if field.data:
try:
field.place = mo.users.validate_and_find_school(field.data)
except mo.CheckError as e:
field.place_error = str(e)
class NewPassword(wtforms.PasswordField):
def __init__(self, label="Nové heslo", validators=None, **kwargs):
super().__init__(label, validators, **kwargs)
def pre_validate(field, form):
if field.data:
if not mo.users.validate_password(field.data):
raise wtforms.ValidationError(mo.users.password_help)
class RepeatPassword(wtforms.PasswordField):
"""Pro validaci hledá ve formuláři form.new_passwd a s ním porovnává."""
def __init__(self, label="Zopakujte heslo", validators=None, **kwargs):
super().__init__(label, validators, **kwargs)
def pre_validate(field, form):
if field.data != form.new_passwd.data:
raise wtforms.ValidationError('Hesla se neshodují.')
class DateTime(wtforms.DateTimeField):
def __init__(self, label, format='%Y-%m-%d %H:%M', description='Ve formátu 2000-01-01 12:34', **kwargs):
super().__init__(label, format=format, description=description, **kwargs)
def process_data(self, valuelist):
super().process_data(valuelist)
if self.data is not None:
self.data = self.data.astimezone()
def process_formdata(self, valuelist):
super().process_formdata(valuelist)
if self.data is not None:
self.data = self.data.astimezone()
......@@ -9,9 +9,9 @@ import urllib.parse
import mo.config as config
import mo.db as db
from mo.rights import Right
import mo.util_format as util_format
from mo.web import app
from mo.web.org_contest import contest_breadcrumbs
from mo.web.org_place import place_breadcrumbs
from mo.web.util import user_html_flags
......@@ -24,6 +24,7 @@ app.jinja_env.trim_blocks = True
# Filtry definované v mo.util_format
app.jinja_env.filters.update(timeformat=util_format.timeformat)
app.jinja_env.filters.update(timeformat_short=util_format.timeformat_short)
app.jinja_env.filters.update(inflected=util_format.inflect_number)
app.jinja_env.filters.update(inflected_by=util_format.inflect_by_number)
app.jinja_env.filters.update(timedelta=util_format.timedelta)
......@@ -46,10 +47,10 @@ app.jinja_env.globals.update(JobState=db.JobState)
# Další typy:
app.jinja_env.globals.update(Markup=Markup)
app.jinja_env.globals.update(Right=Right)
# Vlastní pomocné funkce
app.jinja_env.globals.update(contest_breadcrumbs=contest_breadcrumbs)
app.jinja_env.globals.update(place_breadcrumbs=place_breadcrumbs)
# Funkce asset_url se přidává v mo.ext.assets
......@@ -59,6 +60,7 @@ def user_link(u: db.User) -> Markup:
return Markup('<a href="{url}">{name}{test}</a>').format(url=user_url(u), name=u.full_name(), test=" (test)" if u.is_test else "")
@app.template_filter()
def user_url(u: db.User) -> str:
if u.is_admin or u.is_org:
return url_for('org_org', id=u.user_id)
......@@ -68,7 +70,7 @@ def user_url(u: db.User) -> str:
@app.template_filter()
def pion_link(u: db.User, contest_id: int) -> Markup:
url = url_for('org_contest_user', contest_id=contest_id, user_id=u.user_id)
url = url_for('org_contest_user', ct_id=contest_id, user_id=u.user_id)
return Markup('<a href="{url}">{name}{test}</a>').format(url=url, name=u.full_name(), test=" (test)" if u.is_test else "")
......@@ -93,6 +95,11 @@ def yes_no(a: bool) -> str:
return "ano" if a else "ne"
@app.template_filter()
def jsescape(js: Any) -> str:
return Markup(json_pretty(js))
@app.template_filter()
def json_pretty(js: Any) -> str:
return json.dumps(js, sort_keys=True, indent=4, ensure_ascii=False)
......
......@@ -43,7 +43,8 @@ def get_menu():
name += " [admin]"
items.append(MenuItem(url_for('user_settings'), name, classes=["right"]))
else:
items.append(MenuItem(url_for('login'), "Přihlásit se", active_prefix="/auth/", classes=["right"]))
items.append(MenuItem(url_for('create_acct'), "Založit účet", classes=["right"]))
items.append(MenuItem(url_for('login'), "Přihlásit se", active_prefix="/acct/", classes=["right"]))
active = None
for item in items:
......
from dataclasses import dataclass, field
from flask import render_template, redirect, url_for, request, flash, g
from sqlalchemy import and_, or_
from sqlalchemy.orm import aliased, joinedload
from typing import List, Set, Dict
from typing import List, Set, Optional
import mo.config as config
import mo.db as db
import mo.rights
import mo.users
......@@ -11,6 +13,15 @@ from mo.web.jinja import user_url
from mo.web.table import Table, Row, Column
@dataclass
class OrgOverview:
round: db.Round
place: db.Place
contest: Optional[db.Contest]
role_set: Set[db.RoleType] = field(default_factory=set)
role_list: List[db.RoleType] = field(default_factory=list)
@app.route('/org/')
def org_index():
if 'place' in request.args:
......@@ -32,36 +43,33 @@ def org_index():
flash('ID uživatele musí být číslo', 'danger')
sess = db.get_session()
ctr = (sess.query(db.Contest, db.UserRole)
.select_from(db.UserRole, db.Round, db.Contest)
.filter(and_(db.UserRole.user_id == g.user.user_id,
rcu = (sess.query(db.Round, db.Contest, db.UserRole)
.select_from(db.UserRole)
.join(db.Place)
.join(db.Round, and_(db.UserRole.user_id == g.user.user_id,
or_(db.UserRole.category == None, db.UserRole.category == db.Round.category),
or_(db.UserRole.year == None, db.UserRole.year == db.Round.year),
or_(db.UserRole.seq == None, db.UserRole.seq == db.Round.seq),
db.Round.year == mo.current_year,
db.Contest.round_id == db.Round.round_id,
db.Contest.place_id == db.UserRole.place_id))
.options(joinedload(db.Contest.place))
db.Place.level <= db.Round.level))
.outerjoin(db.Contest, and_(db.Contest.round_id == db.Round.round_id, db.Contest.place_id == db.UserRole.place_id))
.filter(db.Round.year == config.CURRENT_YEAR)
.options(joinedload(db.UserRole.place))
.order_by(db.Round.level, db.Round.category, db.Round.seq, db.Round.part,
db.Contest.place_id, db.Contest.contest_id)
.all())
# Pokud máme pro jednu soutěž více rolí, zkombinujeme je
contests: List[db.Contest] = []
contest_role_sets: Dict[db.Contest, Set[db.RoleType]] = {}
for ct, ur in ctr:
if len(contests) == 0 or contests[-1] != ct:
contests.append(ct)
contest_role_sets[ct.contest_id] = set()
contest_role_sets[ct.contest_id].add(ur.role)
overview: List[OrgOverview] = []
for r, ct, ur in rcu:
o = overview[-1] if overview else None
if not (o and o.round == r and o.place == ur.place):
o = OrgOverview(round=r, place=ur.place, contest=ct)
overview.append(o)
o.role_set.add(ur.role)
# Role pro každou soutěž setřídíme podle důležitosti
contest_roles: Dict[db.Contest, List[db.RoleType]] = {
ct_id: sorted(list(contest_role_sets[ct_id]), key=lambda r: mo.rights.role_order_by_type[r])
for ct_id in contest_role_sets.keys()
}
for o in overview:
o.role_list = sorted(o.role_set, key=lambda r: mo.rights.role_order_by_type[r])
return render_template('org_index.html', contests=contests, contest_roles=contest_roles, role_type_names=db.role_type_names)
return render_template('org_index.html', overview=overview, role_type_names=db.role_type_names)
school_export_columns = (
......@@ -78,8 +86,8 @@ school_export_columns = (
)
@app.route('/org/export/skoly')
def org_export_skoly():
@app.route('/org/export/schools')
def org_export_schools():
sess = db.get_session()
format = request.args.get('format', 'en_csv')
......
This diff is collapsed.
from flask import render_template, g, redirect, url_for, flash
from flask_wtf.form import FlaskForm
import os
from sqlalchemy.orm import joinedload
from typing import Optional
import werkzeug.exceptions
import wtforms
import mo
import mo.db as db
from mo.jobs import TheJob, job_file_size
from mo.jobs import TheJob
from mo.web import app
import mo.web.util
......@@ -60,6 +62,16 @@ def get_job(id: int) -> db.Job:
return job
def job_file_size(job: db.Job, name: Optional[str]) -> Optional[int]:
if name is None:
return None
try:
return os.path.getsize(job.file_path(name))
except OSError:
return -1
@app.route('/org/jobs/<int:id>/')
def org_job(id: int):
job = get_job(id)
......@@ -72,8 +84,8 @@ def org_job(id: int):
'org_job.html',
job=job,
has_errors=has_errors,
in_size=job_file_size(job.in_file),
out_size=job_file_size(job.out_file),
in_size=job_file_size(job, job.in_file),
out_size=job_file_size(job, job.out_file),
)
......